Spaces:
Sleeping
Sleeping
NeonClary Cursor commited on
Commit ·
11bf9b7
1
Parent(s): 0239f6d
feat: human participant turns + credential intake redesign
Browse filesHuman Participant: new human_io service and HumanInputSlot/HumanParticipantModal/HumanTurnIndicator UI; orchestrator and chat API support human turns; ParticipantSidebar/ChatArea/MessageBubble wiring.
Credential intake: new credential_intake prompt, expanded credential service, redesigned Credential Summary modal, prompt registry entry.
Supporting: App state and routing for human turns + credentials, Header surface, ccai.css styling for new components, api/storage helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
- backend/app/api/chat.py +349 -0
- backend/app/services/credential.py +68 -20
- backend/app/services/human_io.py +109 -0
- backend/app/services/models.py +26 -5
- backend/app/services/orchestrator.py +263 -1
- backend/app/services/prompts/__init__.py +6 -0
- backend/app/services/prompts/credential_intake.py +67 -0
- frontend/src/App.js +186 -9
- frontend/src/components/ChatArea.js +19 -1
- frontend/src/components/CredentialSummaryModal.js +162 -7
- frontend/src/components/Header.js +26 -0
- frontend/src/components/HumanInputSlot.js +119 -0
- frontend/src/components/HumanParticipantModal.js +433 -0
- frontend/src/components/HumanTurnIndicator.js +44 -0
- frontend/src/components/MessageBubble.js +10 -2
- frontend/src/components/ParticipantSidebar.js +43 -14
- frontend/src/styles/ccai.css +544 -0
- frontend/src/utils/api.js +80 -0
- frontend/src/utils/storage.js +14 -0
backend/app/api/chat.py
CHANGED
|
@@ -19,7 +19,16 @@ from app.middleware.rate_limit import (
|
|
| 19 |
check_rate_limit,
|
| 20 |
record_conversation,
|
| 21 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from app.services.extra_personas import get_extra_persona
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
from app.services.models import (
|
| 24 |
CONVERSATION_LIMIT_BOUNDS,
|
| 25 |
CONVERSATION_LIMIT_DESCRIPTIONS,
|
|
@@ -120,6 +129,21 @@ class AutoSelectRequest(BaseModel):
|
|
| 120 |
orchestrator_model_id: str | None = None
|
| 121 |
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
class StartChatRequest(BaseModel):
|
| 124 |
question: str | None = None
|
| 125 |
|
|
@@ -135,6 +159,10 @@ class StartChatRequest(BaseModel):
|
|
| 135 |
# silently clamped to the server-side default; see
|
| 136 |
# `clamp_conversation_limits` in services.models.
|
| 137 |
limits: dict[str, int] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
|
| 140 |
# ---------------------------------------------------------------------------
|
|
@@ -355,6 +383,23 @@ def _build_participant(
|
|
| 355 |
f"You are {name}, a Neon.ai persona. Speak naturally in your "
|
| 356 |
"own voice and bring the perspective your background suggests."
|
| 357 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
else:
|
| 359 |
raise HTTPException(400, f"Unknown participant kind: {kind}")
|
| 360 |
|
|
@@ -415,6 +460,20 @@ async def api_start_chat(req: StartChatRequest, request: Request):
|
|
| 415 |
for sel in req.participants:
|
| 416 |
participants.append(_build_participant(sel, expert_lookup, req.model_assignments))
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
record_conversation(request)
|
| 419 |
|
| 420 |
session = create_session()
|
|
@@ -429,6 +488,16 @@ async def api_start_chat(req: StartChatRequest, request: Request):
|
|
| 429 |
session.limits = clamp_conversation_limits(req.limits)
|
| 430 |
session.participant_message_cap = session.limits.participant_message_pause_at
|
| 431 |
session.orchestrator_call_cap = session.limits.orchestrator_call_pause_at
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
async def event_stream():
|
| 434 |
yield (
|
|
@@ -467,6 +536,286 @@ async def api_continue(session_id: str, reason: str = "messages"):
|
|
| 467 |
return {"ok": True, "reason": reason}
|
| 468 |
|
| 469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
# ---------------------------------------------------------------------------
|
| 471 |
# Exports
|
| 472 |
# ---------------------------------------------------------------------------
|
|
|
|
| 19 |
check_rate_limit,
|
| 20 |
record_conversation,
|
| 21 |
)
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from app.services import human_io
|
| 25 |
+
from app.services.credential import normalize_one_credential
|
| 26 |
from app.services.extra_personas import get_extra_persona
|
| 27 |
+
from app.services.json_calls import orchestrator_call
|
| 28 |
+
from app.services.prompts import (
|
| 29 |
+
CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT,
|
| 30 |
+
CREDENTIAL_INTAKE_TURN_PROMPT,
|
| 31 |
+
)
|
| 32 |
from app.services.models import (
|
| 33 |
CONVERSATION_LIMIT_BOUNDS,
|
| 34 |
CONVERSATION_LIMIT_DESCRIPTIONS,
|
|
|
|
| 129 |
orchestrator_model_id: str | None = None
|
| 130 |
|
| 131 |
|
| 132 |
+
class HumanCredentialPayload(BaseModel):
|
| 133 |
+
"""User-authored credential summary for the in-the-loop human.
|
| 134 |
+
|
| 135 |
+
The orchestrator prepends this entry to the LLM-built credential
|
| 136 |
+
summary so the human always appears first in the modal / exports.
|
| 137 |
+
"""
|
| 138 |
+
|
| 139 |
+
participant_id: str
|
| 140 |
+
name: str
|
| 141 |
+
expertise: str = ""
|
| 142 |
+
personality: str = ""
|
| 143 |
+
credibility_for_question: float = 0.5
|
| 144 |
+
bias_to_watch: str = ""
|
| 145 |
+
|
| 146 |
+
|
| 147 |
class StartChatRequest(BaseModel):
|
| 148 |
question: str | None = None
|
| 149 |
|
|
|
|
| 159 |
# silently clamped to the server-side default; see
|
| 160 |
# `clamp_conversation_limits` in services.models.
|
| 161 |
limits: dict[str, int] | None = None
|
| 162 |
+
# Optional in-the-loop human participant's pre-authored credential
|
| 163 |
+
# summary. Must reference a participant in the `participants` list
|
| 164 |
+
# that has kind == "human". Capped at one human per session.
|
| 165 |
+
human_credential: HumanCredentialPayload | None = None
|
| 166 |
|
| 167 |
|
| 168 |
# ---------------------------------------------------------------------------
|
|
|
|
| 383 |
f"You are {name}, a Neon.ai persona. Speak naturally in your "
|
| 384 |
"own voice and bring the perspective your background suggests."
|
| 385 |
)
|
| 386 |
+
elif kind == "human":
|
| 387 |
+
# Human participants don't use an LLM at all; the orchestrator
|
| 388 |
+
# pauses for their typed input. They still need a participant
|
| 389 |
+
# row so the rest of the state machine (credential summary,
|
| 390 |
+
# alliance detection, addressed-to routing, etc.) can refer to
|
| 391 |
+
# them by id and name.
|
| 392 |
+
if not name:
|
| 393 |
+
raise HTTPException(400, "Human participant requires a name")
|
| 394 |
+
return Participant(
|
| 395 |
+
participant_id=pid,
|
| 396 |
+
name=name,
|
| 397 |
+
role_prompt="",
|
| 398 |
+
model_id="",
|
| 399 |
+
kind="human",
|
| 400 |
+
enabled=True,
|
| 401 |
+
display_name="Human participant",
|
| 402 |
+
)
|
| 403 |
else:
|
| 404 |
raise HTTPException(400, f"Unknown participant kind: {kind}")
|
| 405 |
|
|
|
|
| 460 |
for sel in req.participants:
|
| 461 |
participants.append(_build_participant(sel, expert_lookup, req.model_assignments))
|
| 462 |
|
| 463 |
+
humans = [p for p in participants if p.kind == "human"]
|
| 464 |
+
if len(humans) > 1:
|
| 465 |
+
raise HTTPException(400, "Only one human participant is supported per session.")
|
| 466 |
+
if humans and req.human_credential is None:
|
| 467 |
+
raise HTTPException(
|
| 468 |
+
400,
|
| 469 |
+
"Human participant requires a human_credential payload.",
|
| 470 |
+
)
|
| 471 |
+
if humans and req.human_credential.participant_id != humans[0].participant_id:
|
| 472 |
+
raise HTTPException(
|
| 473 |
+
400,
|
| 474 |
+
"human_credential.participant_id must match the human participant.",
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
record_conversation(request)
|
| 478 |
|
| 479 |
session = create_session()
|
|
|
|
| 488 |
session.limits = clamp_conversation_limits(req.limits)
|
| 489 |
session.participant_message_cap = session.limits.participant_message_pause_at
|
| 490 |
session.orchestrator_call_cap = session.limits.orchestrator_call_pause_at
|
| 491 |
+
if humans and req.human_credential is not None:
|
| 492 |
+
session.human_credential = normalize_one_credential({
|
| 493 |
+
"participant_id": req.human_credential.participant_id,
|
| 494 |
+
"name": req.human_credential.name,
|
| 495 |
+
"expertise": req.human_credential.expertise,
|
| 496 |
+
"personality": req.human_credential.personality,
|
| 497 |
+
"credibility_for_question": req.human_credential.credibility_for_question,
|
| 498 |
+
"bias_to_watch": req.human_credential.bias_to_watch,
|
| 499 |
+
"is_human": True,
|
| 500 |
+
})
|
| 501 |
|
| 502 |
async def event_stream():
|
| 503 |
yield (
|
|
|
|
| 536 |
return {"ok": True, "reason": reason}
|
| 537 |
|
| 538 |
|
| 539 |
+
# ---------------------------------------------------------------------------
|
| 540 |
+
# Human participant: turn response + credential intake Q&A
|
| 541 |
+
# ---------------------------------------------------------------------------
|
| 542 |
+
|
| 543 |
+
class HumanResponseRequest(BaseModel):
|
| 544 |
+
"""POST body for the human's response to a pending turn.
|
| 545 |
+
|
| 546 |
+
`skip` flips this turn into a "declined to comment" note from the
|
| 547 |
+
orchestrator rather than a participant message; `text` is ignored
|
| 548 |
+
when skip is true.
|
| 549 |
+
"""
|
| 550 |
+
|
| 551 |
+
text: str = ""
|
| 552 |
+
skip: bool = False
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
@router.post("/chat/{session_id}/human-response")
|
| 556 |
+
async def api_human_response(session_id: str, req: HumanResponseRequest):
|
| 557 |
+
"""Deliver the human participant's text for the current pending
|
| 558 |
+
turn. Wakes the orchestrator coroutine waiting on the
|
| 559 |
+
`human_io` slot for this session."""
|
| 560 |
+
session = get_session(session_id)
|
| 561 |
+
if not session:
|
| 562 |
+
raise HTTPException(404, "Session not found")
|
| 563 |
+
if session.awaiting_human is None:
|
| 564 |
+
raise HTTPException(409, "Session is not awaiting a human turn")
|
| 565 |
+
if not req.skip and not (req.text or "").strip():
|
| 566 |
+
raise HTTPException(400, "text is required unless skip is true")
|
| 567 |
+
delivered = human_io.deliver_human_response(
|
| 568 |
+
session_id, req.text, skip=req.skip,
|
| 569 |
+
)
|
| 570 |
+
if not delivered:
|
| 571 |
+
raise HTTPException(409, "No pending human turn for this session")
|
| 572 |
+
return {"ok": True, "skipped": req.skip}
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
class HumanCredentialEditRequest(BaseModel):
|
| 576 |
+
"""PATCH body for editing the human's credential summary mid-chat."""
|
| 577 |
+
|
| 578 |
+
name: str | None = None
|
| 579 |
+
expertise: str | None = None
|
| 580 |
+
personality: str | None = None
|
| 581 |
+
credibility_for_question: float | None = None
|
| 582 |
+
bias_to_watch: str | None = None
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
@router.patch("/chat/{session_id}/credentials/human")
|
| 586 |
+
async def api_edit_human_credential(
|
| 587 |
+
session_id: str,
|
| 588 |
+
req: HumanCredentialEditRequest,
|
| 589 |
+
):
|
| 590 |
+
"""Update the human participant's credential summary in place.
|
| 591 |
+
|
| 592 |
+
The View Credential Summary modal lets the user tweak the human's
|
| 593 |
+
entry (name, expertise, style, credibility, bias). Only fields
|
| 594 |
+
provided in the body are changed; others are left as-is. The
|
| 595 |
+
updated entry is reflected in subsequent participant prompts (the
|
| 596 |
+
credentials_to_block call rebuilds the prompt block each turn).
|
| 597 |
+
"""
|
| 598 |
+
session = get_session(session_id)
|
| 599 |
+
if not session:
|
| 600 |
+
raise HTTPException(404, "Session not found")
|
| 601 |
+
if session.human_credential is None:
|
| 602 |
+
raise HTTPException(404, "Session has no human participant")
|
| 603 |
+
|
| 604 |
+
updated = dict(session.human_credential)
|
| 605 |
+
for field_name in (
|
| 606 |
+
"name", "expertise", "personality",
|
| 607 |
+
"credibility_for_question", "bias_to_watch",
|
| 608 |
+
):
|
| 609 |
+
value = getattr(req, field_name)
|
| 610 |
+
if value is not None:
|
| 611 |
+
updated[field_name] = value
|
| 612 |
+
updated["is_human"] = True
|
| 613 |
+
updated = normalize_one_credential(updated)
|
| 614 |
+
session.human_credential = updated
|
| 615 |
+
|
| 616 |
+
# Also patch the entry inside session.credential_summary so the
|
| 617 |
+
# View Credential Summary modal reflects the edit without waiting
|
| 618 |
+
# for the next phase-refresh.
|
| 619 |
+
for i, c in enumerate(session.credential_summary or []):
|
| 620 |
+
if c.get("participant_id") == updated["participant_id"]:
|
| 621 |
+
session.credential_summary[i] = updated
|
| 622 |
+
break
|
| 623 |
+
|
| 624 |
+
return {"ok": True, "credential": updated}
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
# Module-level registry of in-flight credential drafts. Each draft is a
|
| 628 |
+
# tiny piece of state: the question being discussed, the human's name,
|
| 629 |
+
# the question/answer history, and the configured cap. Drafts are
|
| 630 |
+
# transient (lifetime = a few seconds of Q&A in the modal) so we don't
|
| 631 |
+
# bother persisting them; the registry is cleared by the API when the
|
| 632 |
+
# draft is finalized or abandoned.
|
| 633 |
+
_credential_drafts: dict[str, dict[str, Any]] = {}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
class CredentialDraftStartRequest(BaseModel):
|
| 637 |
+
"""Body for POST /api/chat/credentials/draft - kicks off a Q&A."""
|
| 638 |
+
|
| 639 |
+
name: str
|
| 640 |
+
question: str
|
| 641 |
+
max_questions: int = 6
|
| 642 |
+
orchestrator_model_id: str | None = None
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
class CredentialDraftAnswerRequest(BaseModel):
|
| 646 |
+
"""Body for POST /api/chat/credentials/draft/{draft_id}/answer."""
|
| 647 |
+
|
| 648 |
+
answer: str = ""
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def _intake_transcript(history: list[dict[str, str]]) -> str:
|
| 652 |
+
"""Render the Q&A history into a transcript snippet for the prompt.
|
| 653 |
+
|
| 654 |
+
Each entry of history is {"q": "...", "a": "..."}. The last entry
|
| 655 |
+
may have only "q" (the question the user is currently answering)
|
| 656 |
+
when called BEFORE the first answer, but in practice we render
|
| 657 |
+
history only after the LLM has emitted a question and the user has
|
| 658 |
+
answered, so both keys are present.
|
| 659 |
+
"""
|
| 660 |
+
if not history:
|
| 661 |
+
return CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT
|
| 662 |
+
lines: list[str] = []
|
| 663 |
+
for i, qa in enumerate(history, start=1):
|
| 664 |
+
q = (qa.get("q") or "").strip()
|
| 665 |
+
a = (qa.get("a") or "").strip()
|
| 666 |
+
lines.append(f"Q{i}: {q}")
|
| 667 |
+
lines.append(f"A{i}: {a}" if a else f"A{i}: (no answer yet)")
|
| 668 |
+
return "\n".join(lines)
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
async def _intake_turn(draft: dict[str, Any]) -> dict[str, Any]:
|
| 672 |
+
"""Run one orchestrator turn for the credential intake Q&A.
|
| 673 |
+
|
| 674 |
+
Returns either {"kind": "question", "text": ...} or
|
| 675 |
+
{"kind": "summary", "summary": {...}} as parsed from the
|
| 676 |
+
orchestrator's JSON output. Falls back to a safe default question
|
| 677 |
+
if parsing fails.
|
| 678 |
+
"""
|
| 679 |
+
transcript = _intake_transcript(draft["history"])
|
| 680 |
+
prompt = CREDENTIAL_INTAKE_TURN_PROMPT.format(
|
| 681 |
+
name=draft["name"],
|
| 682 |
+
question=draft["question"],
|
| 683 |
+
max_questions=draft["max_questions"],
|
| 684 |
+
questions_asked=draft["questions_asked"],
|
| 685 |
+
transcript=transcript,
|
| 686 |
+
)
|
| 687 |
+
_raw, parsed = await orchestrator_call(
|
| 688 |
+
orchestrator_model_id=draft["orchestrator_model_id"],
|
| 689 |
+
user_prompt=prompt,
|
| 690 |
+
label="credential_intake",
|
| 691 |
+
api_log=draft.get("api_log"),
|
| 692 |
+
max_tokens=512,
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
if isinstance(parsed, dict):
|
| 696 |
+
kind = parsed.get("kind")
|
| 697 |
+
if kind == "summary" and isinstance(parsed.get("summary"), dict):
|
| 698 |
+
return {"kind": "summary", "summary": parsed["summary"]}
|
| 699 |
+
if kind == "question" and isinstance(parsed.get("text"), str):
|
| 700 |
+
return {"kind": "question", "text": parsed["text"].strip()}
|
| 701 |
+
|
| 702 |
+
# Defensive fallback: if the model returned garbage, ask a sensible
|
| 703 |
+
# next-question rather than crashing the modal.
|
| 704 |
+
if draft["questions_asked"] >= draft["max_questions"]:
|
| 705 |
+
return {
|
| 706 |
+
"kind": "summary",
|
| 707 |
+
"summary": {
|
| 708 |
+
"name": draft["name"],
|
| 709 |
+
"expertise": "(intake LLM did not return a summary)",
|
| 710 |
+
"personality": "",
|
| 711 |
+
"credibility_for_question": 0.5,
|
| 712 |
+
"bias_to_watch": "",
|
| 713 |
+
},
|
| 714 |
+
}
|
| 715 |
+
return {
|
| 716 |
+
"kind": "question",
|
| 717 |
+
"text": (
|
| 718 |
+
"Could you tell me a bit about your background relevant to "
|
| 719 |
+
f'this question: "{draft["question"]}"?'
|
| 720 |
+
),
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
@router.post("/chat/credentials/draft")
|
| 725 |
+
async def api_credential_draft_start(req: CredentialDraftStartRequest):
|
| 726 |
+
"""Kick off a new credential-intake Q&A. Returns the draft id plus
|
| 727 |
+
the LLM's first question (or, if it bailed immediately, a final
|
| 728 |
+
summary)."""
|
| 729 |
+
if not req.name.strip():
|
| 730 |
+
raise HTTPException(400, "name is required")
|
| 731 |
+
if not req.question.strip():
|
| 732 |
+
raise HTTPException(400, "question is required")
|
| 733 |
+
max_q = max(1, min(10, int(req.max_questions or 6)))
|
| 734 |
+
|
| 735 |
+
import uuid as _uuid
|
| 736 |
+
draft_id = str(_uuid.uuid4())
|
| 737 |
+
draft: dict[str, Any] = {
|
| 738 |
+
"draft_id": draft_id,
|
| 739 |
+
"name": req.name.strip(),
|
| 740 |
+
"question": req.question.strip(),
|
| 741 |
+
"max_questions": max_q,
|
| 742 |
+
"questions_asked": 0,
|
| 743 |
+
"history": [],
|
| 744 |
+
"orchestrator_model_id": (
|
| 745 |
+
req.orchestrator_model_id or settings.orchestrator_model
|
| 746 |
+
),
|
| 747 |
+
"api_log": [],
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
result = await _intake_turn(draft)
|
| 751 |
+
if result["kind"] == "question":
|
| 752 |
+
draft["questions_asked"] += 1
|
| 753 |
+
draft["history"].append({"q": result["text"], "a": ""})
|
| 754 |
+
_credential_drafts[draft_id] = draft
|
| 755 |
+
return {
|
| 756 |
+
"draft_id": draft_id,
|
| 757 |
+
"kind": "question",
|
| 758 |
+
"question": result["text"],
|
| 759 |
+
"questions_asked": draft["questions_asked"],
|
| 760 |
+
"max_questions": max_q,
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
# The intake LLM jumped straight to a summary (no answers needed).
|
| 764 |
+
return {
|
| 765 |
+
"draft_id": draft_id,
|
| 766 |
+
"kind": "summary",
|
| 767 |
+
"summary": result["summary"],
|
| 768 |
+
"questions_asked": 0,
|
| 769 |
+
"max_questions": max_q,
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
@router.post("/chat/credentials/draft/{draft_id}/answer")
|
| 774 |
+
async def api_credential_draft_answer(
|
| 775 |
+
draft_id: str,
|
| 776 |
+
req: CredentialDraftAnswerRequest,
|
| 777 |
+
):
|
| 778 |
+
"""Submit the human's answer to the last question; receive either
|
| 779 |
+
the LLM's next question or the final credential summary."""
|
| 780 |
+
draft = _credential_drafts.get(draft_id)
|
| 781 |
+
if draft is None:
|
| 782 |
+
raise HTTPException(404, "Draft not found or already finalized")
|
| 783 |
+
if not draft["history"]:
|
| 784 |
+
raise HTTPException(409, "Draft has no pending question to answer")
|
| 785 |
+
# Stamp the answer onto the last question.
|
| 786 |
+
draft["history"][-1]["a"] = (req.answer or "").strip()
|
| 787 |
+
|
| 788 |
+
result = await _intake_turn(draft)
|
| 789 |
+
if result["kind"] == "question":
|
| 790 |
+
draft["questions_asked"] += 1
|
| 791 |
+
draft["history"].append({"q": result["text"], "a": ""})
|
| 792 |
+
return {
|
| 793 |
+
"draft_id": draft_id,
|
| 794 |
+
"kind": "question",
|
| 795 |
+
"question": result["text"],
|
| 796 |
+
"questions_asked": draft["questions_asked"],
|
| 797 |
+
"max_questions": draft["max_questions"],
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
# Final summary; clear the draft from the registry.
|
| 801 |
+
_credential_drafts.pop(draft_id, None)
|
| 802 |
+
return {
|
| 803 |
+
"draft_id": draft_id,
|
| 804 |
+
"kind": "summary",
|
| 805 |
+
"summary": result["summary"],
|
| 806 |
+
"questions_asked": draft["questions_asked"],
|
| 807 |
+
"max_questions": draft["max_questions"],
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
@router.delete("/chat/credentials/draft/{draft_id}")
|
| 812 |
+
async def api_credential_draft_cancel(draft_id: str):
|
| 813 |
+
"""User abandoned the AI Q&A (e.g. closed the modal). No-op if
|
| 814 |
+
already gone."""
|
| 815 |
+
_credential_drafts.pop(draft_id, None)
|
| 816 |
+
return {"ok": True}
|
| 817 |
+
|
| 818 |
+
|
| 819 |
# ---------------------------------------------------------------------------
|
| 820 |
# Exports
|
| 821 |
# ---------------------------------------------------------------------------
|
backend/app/services/credential.py
CHANGED
|
@@ -64,26 +64,40 @@ async def build_credential_summary(
|
|
| 64 |
participants: list[Any],
|
| 65 |
initial_opinions: dict[str, str],
|
| 66 |
api_log: list[dict[str, Any]] | None = None,
|
|
|
|
| 67 |
) -> list[dict[str, Any]]:
|
| 68 |
-
"""Build the Credential Summary list. Returns an empty list on parse failure.
|
| 69 |
-
block = _format_participants_block(participants, initial_opinions)
|
| 70 |
-
prompt = CREDENTIAL_BUILD_PROMPT.format(
|
| 71 |
-
question=question,
|
| 72 |
-
participants_block=block,
|
| 73 |
-
)
|
| 74 |
-
_raw, parsed = await orchestrator_call(
|
| 75 |
-
orchestrator_model_id=orchestrator_model_id,
|
| 76 |
-
user_prompt=prompt,
|
| 77 |
-
label="build_credentials",
|
| 78 |
-
api_log=api_log,
|
| 79 |
-
max_tokens=2048,
|
| 80 |
-
)
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
creds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
return creds
|
| 88 |
|
| 89 |
|
|
@@ -96,12 +110,26 @@ async def refresh_credential_summary(
|
|
| 96 |
critique_transcript: str,
|
| 97 |
api_log: list[dict[str, Any]] | None = None,
|
| 98 |
) -> list[dict[str, Any]]:
|
| 99 |
-
"""Refresh the Credential Summary after Phase 2 critique.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if not existing:
|
| 101 |
return existing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
prompt = CREDENTIAL_REFRESH_PROMPT.format(
|
| 103 |
question=question,
|
| 104 |
-
credential_summary_json=json.dumps({"credentials":
|
| 105 |
critique_transcript=critique_transcript,
|
| 106 |
)
|
| 107 |
_raw, parsed = await orchestrator_call(
|
|
@@ -112,10 +140,30 @@ async def refresh_credential_summary(
|
|
| 112 |
max_tokens=2048,
|
| 113 |
)
|
| 114 |
if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
|
| 115 |
-
|
|
|
|
| 116 |
return existing
|
| 117 |
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
def _normalize_creds(
|
| 120 |
creds: list[dict[str, Any]],
|
| 121 |
participants: list[Any],
|
|
|
|
| 64 |
participants: list[Any],
|
| 65 |
initial_opinions: dict[str, str],
|
| 66 |
api_log: list[dict[str, Any]] | None = None,
|
| 67 |
+
human_credential: dict[str, Any] | None = None,
|
| 68 |
) -> list[dict[str, Any]]:
|
| 69 |
+
"""Build the Credential Summary list. Returns an empty list on parse failure.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
Human participants (kind == "human") are NOT sent to the LLM - the
|
| 72 |
+
user already authored their own credential summary in the
|
| 73 |
+
HumanParticipantModal. We prepend that entry to the front of the
|
| 74 |
+
returned list so the human always appears first in the modal /
|
| 75 |
+
export, and we exclude them from the LLM input so the orchestrator
|
| 76 |
+
isn't asked to fabricate facts about a person.
|
| 77 |
+
"""
|
| 78 |
+
llm_participants = [p for p in participants if getattr(p, "kind", "") != "human"]
|
| 79 |
|
| 80 |
+
creds: list[dict[str, Any]] = []
|
| 81 |
+
if llm_participants:
|
| 82 |
+
block = _format_participants_block(llm_participants, initial_opinions)
|
| 83 |
+
prompt = CREDENTIAL_BUILD_PROMPT.format(
|
| 84 |
+
question=question,
|
| 85 |
+
participants_block=block,
|
| 86 |
+
)
|
| 87 |
+
_raw, parsed = await orchestrator_call(
|
| 88 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 89 |
+
user_prompt=prompt,
|
| 90 |
+
label="build_credentials",
|
| 91 |
+
api_log=api_log,
|
| 92 |
+
max_tokens=2048,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
|
| 96 |
+
creds = parsed["credentials"]
|
| 97 |
+
|
| 98 |
+
creds = _normalize_creds(creds, llm_participants)
|
| 99 |
+
if human_credential:
|
| 100 |
+
creds = [normalize_one_credential(human_credential)] + creds
|
| 101 |
return creds
|
| 102 |
|
| 103 |
|
|
|
|
| 110 |
critique_transcript: str,
|
| 111 |
api_log: list[dict[str, Any]] | None = None,
|
| 112 |
) -> list[dict[str, Any]]:
|
| 113 |
+
"""Refresh the Credential Summary after Phase 2 critique.
|
| 114 |
+
|
| 115 |
+
Human entries (kind == "human") are passed through verbatim - we
|
| 116 |
+
don't ask the LLM to second-guess the user's self-description. The
|
| 117 |
+
LLM only refreshes credentials for LLM participants.
|
| 118 |
+
"""
|
| 119 |
if not existing:
|
| 120 |
return existing
|
| 121 |
+
|
| 122 |
+
human_pids = {p.participant_id for p in participants if getattr(p, "kind", "") == "human"}
|
| 123 |
+
human_entries = [c for c in existing if c.get("participant_id") in human_pids]
|
| 124 |
+
llm_entries = [c for c in existing if c.get("participant_id") not in human_pids]
|
| 125 |
+
llm_participants = [p for p in participants if getattr(p, "kind", "") != "human"]
|
| 126 |
+
|
| 127 |
+
if not llm_entries:
|
| 128 |
+
return existing
|
| 129 |
+
|
| 130 |
prompt = CREDENTIAL_REFRESH_PROMPT.format(
|
| 131 |
question=question,
|
| 132 |
+
credential_summary_json=json.dumps({"credentials": llm_entries}, indent=2),
|
| 133 |
critique_transcript=critique_transcript,
|
| 134 |
)
|
| 135 |
_raw, parsed = await orchestrator_call(
|
|
|
|
| 140 |
max_tokens=2048,
|
| 141 |
)
|
| 142 |
if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
|
| 143 |
+
refreshed_llm = _normalize_creds(parsed["credentials"], llm_participants)
|
| 144 |
+
return human_entries + refreshed_llm
|
| 145 |
return existing
|
| 146 |
|
| 147 |
|
| 148 |
+
def normalize_one_credential(c: dict[str, Any]) -> dict[str, Any]:
|
| 149 |
+
"""Clamp credibility to [0, 1] and ensure required keys exist on a
|
| 150 |
+
single credential dict. Used for human-authored entries that bypass
|
| 151 |
+
the LLM-side _normalize_creds roster pass."""
|
| 152 |
+
try:
|
| 153 |
+
score = float(c.get("credibility_for_question", 0.5))
|
| 154 |
+
except Exception:
|
| 155 |
+
score = 0.5
|
| 156 |
+
return {
|
| 157 |
+
"participant_id": c.get("participant_id") or c.get("id") or "",
|
| 158 |
+
"name": c.get("name", ""),
|
| 159 |
+
"expertise": c.get("expertise", ""),
|
| 160 |
+
"personality": c.get("personality", ""),
|
| 161 |
+
"credibility_for_question": max(0.0, min(1.0, score)),
|
| 162 |
+
"bias_to_watch": c.get("bias_to_watch", ""),
|
| 163 |
+
"is_human": bool(c.get("is_human", True)),
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
def _normalize_creds(
|
| 168 |
creds: list[dict[str, Any]],
|
| 169 |
participants: list[Any],
|
backend/app/services/human_io.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Human-participant I/O coordination.
|
| 2 |
+
|
| 3 |
+
Bridges the streaming SSE orchestrator to the asynchronous HTTP flow
|
| 4 |
+
where a human submits their response via POST. Mirrors the shape of
|
| 5 |
+
the existing failsafe pause:
|
| 6 |
+
|
| 7 |
+
1. Orchestrator reaches a turn for a human participant.
|
| 8 |
+
2. It populates `session.awaiting_human` and yields a
|
| 9 |
+
`human_turn_needed` SSE event, then awaits this module's slot.
|
| 10 |
+
3. The frontend renders the green-bordered input box and a
|
| 11 |
+
lower-screen "waiting for your input" indicator.
|
| 12 |
+
4. User clicks Submit (or Skip) -> POST /api/chat/{id}/human-response.
|
| 13 |
+
5. The API layer calls `deliver_human_response`, which sets the
|
| 14 |
+
slot's `asyncio.Event` and wakes the orchestrator. Orchestrator
|
| 15 |
+
yields `human_turn_cleared`, appends the message (or a skip note),
|
| 16 |
+
and proceeds.
|
| 17 |
+
|
| 18 |
+
Slot lifetime: lazily created per session_id on first wait, then
|
| 19 |
+
reused turn-after-turn. Cleared via `drop_session` when the session
|
| 20 |
+
ends (currently called from the orchestrator's finally block on
|
| 21 |
+
conversation completion).
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import asyncio
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
from typing import Any
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class HumanTurnSlot:
|
| 32 |
+
"""Per-session waiting-room for a single pending human turn.
|
| 33 |
+
|
| 34 |
+
`event` is set by the API layer when the user submits or skips.
|
| 35 |
+
`response_text` and `skipped` carry the payload across the event.
|
| 36 |
+
`started_at` is wall-clock time stamped when the orchestrator
|
| 37 |
+
starts waiting; the orchestrator subtracts it from now() to get
|
| 38 |
+
`elapsed_seconds` for the message bubble.
|
| 39 |
+
|
| 40 |
+
`pending_snapshot` stashes the result of
|
| 41 |
+
`_pending_addressed_for(session, participant)` at turn-start so the
|
| 42 |
+
orchestrator can stamp `replying_to` on the eventual message
|
| 43 |
+
without re-walking the transcript after the user types.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
event: asyncio.Event = field(default_factory=asyncio.Event)
|
| 47 |
+
response_text: str = ""
|
| 48 |
+
skipped: bool = False
|
| 49 |
+
started_at: float = 0.0
|
| 50 |
+
pending_snapshot: list[Any] = field(default_factory=list)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
_slots: dict[str, HumanTurnSlot] = {}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def slot_for(session_id: str) -> HumanTurnSlot:
|
| 57 |
+
"""Get-or-create the slot for this session_id."""
|
| 58 |
+
slot = _slots.get(session_id)
|
| 59 |
+
if slot is None:
|
| 60 |
+
slot = HumanTurnSlot()
|
| 61 |
+
_slots[session_id] = slot
|
| 62 |
+
return slot
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def reset_slot(session_id: str) -> None:
|
| 66 |
+
"""Clear payload state in the slot so the next turn starts fresh.
|
| 67 |
+
|
| 68 |
+
Idempotent: a no-op if no slot exists. The Event object itself is
|
| 69 |
+
retained (cleared) so any callers that captured a reference keep
|
| 70 |
+
working across turns.
|
| 71 |
+
"""
|
| 72 |
+
slot = _slots.get(session_id)
|
| 73 |
+
if slot is None:
|
| 74 |
+
return
|
| 75 |
+
slot.event.clear()
|
| 76 |
+
slot.response_text = ""
|
| 77 |
+
slot.skipped = False
|
| 78 |
+
slot.started_at = 0.0
|
| 79 |
+
slot.pending_snapshot = []
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def deliver_human_response(
|
| 83 |
+
session_id: str,
|
| 84 |
+
text: str,
|
| 85 |
+
*,
|
| 86 |
+
skip: bool = False,
|
| 87 |
+
) -> bool:
|
| 88 |
+
"""Wake the orchestrator's wait on this session's human turn.
|
| 89 |
+
|
| 90 |
+
Returns True if a slot was waiting; False if there was nothing
|
| 91 |
+
pending (e.g. user double-clicked Submit, or sent a response after
|
| 92 |
+
the orchestrator already moved on). Callers can surface that as a
|
| 93 |
+
409 to the frontend.
|
| 94 |
+
"""
|
| 95 |
+
slot = _slots.get(session_id)
|
| 96 |
+
if slot is None:
|
| 97 |
+
return False
|
| 98 |
+
if slot.event.is_set():
|
| 99 |
+
# Idempotent: already delivered, treat as no-op.
|
| 100 |
+
return False
|
| 101 |
+
slot.response_text = text or ""
|
| 102 |
+
slot.skipped = bool(skip)
|
| 103 |
+
slot.event.set()
|
| 104 |
+
return True
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def drop_session(session_id: str) -> None:
|
| 108 |
+
"""Cleanup at session-end so old slots don't accumulate."""
|
| 109 |
+
_slots.pop(session_id, None)
|
backend/app/services/models.py
CHANGED
|
@@ -231,9 +231,15 @@ class Participant:
|
|
| 231 |
"""One member of the CCAI forum.
|
| 232 |
|
| 233 |
`kind` distinguishes Neon HANA personas, the four bundled "extra"
|
| 234 |
-
personas,
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
"""
|
| 238 |
|
| 239 |
participant_id: str
|
|
@@ -241,7 +247,7 @@ class Participant:
|
|
| 241 |
role_prompt: str
|
| 242 |
model_id: str
|
| 243 |
|
| 244 |
-
kind: str = "expert" # "neon" | "extra" | "expert"
|
| 245 |
enabled: bool = True
|
| 246 |
|
| 247 |
# Resolved provider routing (populated from settings.resolve_model)
|
|
@@ -323,9 +329,24 @@ class Session:
|
|
| 323 |
orchestrator_call_cap: int = ORCHESTRATOR_CALL_PAUSE_AT
|
| 324 |
|
| 325 |
paused_for_continue: bool = False
|
| 326 |
-
pause_reason: str | None = None # "messages" | "orchestrator"
|
| 327 |
finished: bool = False
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
# Streaming control: the orchestrator state-machine writes to this and
|
| 330 |
# the API layer reads it.
|
| 331 |
api_log: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
| 231 |
"""One member of the CCAI forum.
|
| 232 |
|
| 233 |
`kind` distinguishes Neon HANA personas, the four bundled "extra"
|
| 234 |
+
personas, user-created Expert Personas, and an optional in-the-loop
|
| 235 |
+
human participant. `enabled` reflects the sidebar slider. Disabled
|
| 236 |
+
participants are kept on the session so the user can re-enable
|
| 237 |
+
mid-conversation, but they don't take turns.
|
| 238 |
+
|
| 239 |
+
Human participants have `kind == "human"`, no `model_id`, and an
|
| 240 |
+
empty `role_prompt`. The orchestrator pauses for their input via
|
| 241 |
+
SSE instead of calling an LLM; the user supplies their text through
|
| 242 |
+
POST /api/chat/{id}/human-response.
|
| 243 |
"""
|
| 244 |
|
| 245 |
participant_id: str
|
|
|
|
| 247 |
role_prompt: str
|
| 248 |
model_id: str
|
| 249 |
|
| 250 |
+
kind: str = "expert" # "neon" | "extra" | "expert" | "human"
|
| 251 |
enabled: bool = True
|
| 252 |
|
| 253 |
# Resolved provider routing (populated from settings.resolve_model)
|
|
|
|
| 329 |
orchestrator_call_cap: int = ORCHESTRATOR_CALL_PAUSE_AT
|
| 330 |
|
| 331 |
paused_for_continue: bool = False
|
| 332 |
+
pause_reason: str | None = None # "messages" | "orchestrator" | "human_turn"
|
| 333 |
finished: bool = False
|
| 334 |
|
| 335 |
+
# While the orchestrator is awaiting the human participant's text,
|
| 336 |
+
# this carries the metadata the frontend needs to render the input
|
| 337 |
+
# slot (speaker_id, name, phase, etc.). None when no human turn is
|
| 338 |
+
# pending. The session is paused_for_continue while this is set.
|
| 339 |
+
awaiting_human: dict[str, Any] | None = None
|
| 340 |
+
|
| 341 |
+
# User-authored credential summary for the in-the-loop human
|
| 342 |
+
# participant (kind == "human"). None when there is no human in
|
| 343 |
+
# this session. The orchestrator prepends this entry to the
|
| 344 |
+
# LLM-built credential summary so the human always appears first
|
| 345 |
+
# in the View Credential Summary modal and exports. Schema:
|
| 346 |
+
# {participant_id, name, expertise, personality,
|
| 347 |
+
# credibility_for_question (float 0..1), bias_to_watch}
|
| 348 |
+
human_credential: dict[str, Any] | None = None
|
| 349 |
+
|
| 350 |
# Streaming control: the orchestrator state-machine writes to this and
|
| 351 |
# the API layer reads it.
|
| 352 |
api_log: list[dict[str, Any]] = field(default_factory=list)
|
backend/app/services/orchestrator.py
CHANGED
|
@@ -31,7 +31,7 @@ from typing import Any, AsyncIterator
|
|
| 31 |
|
| 32 |
from app.clients.llm_router import chat_completion
|
| 33 |
from app.config import settings
|
| 34 |
-
from app.services import context_budget
|
| 35 |
from app.services.consensus import (
|
| 36 |
assess_consensus_status,
|
| 37 |
classify_addressed_to,
|
|
@@ -292,6 +292,165 @@ async def _wait_for_continue(
|
|
| 292 |
yield _sse("status", {"message": "Resuming conversation..."})
|
| 293 |
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
# ---------------------------------------------------------------------------
|
| 296 |
# Participant turn (with context budgeting + summarize-on-demand)
|
| 297 |
# ---------------------------------------------------------------------------
|
|
@@ -430,6 +589,11 @@ def _add_participant_message(
|
|
| 430 |
"speaker_id": participant.participant_id,
|
| 431 |
"speaker_name": participant.name,
|
| 432 |
"role": "participant",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
"text": text,
|
| 434 |
"phase": phase.value,
|
| 435 |
"timestamp": time.time(),
|
|
@@ -486,6 +650,17 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
|
|
| 486 |
|
| 487 |
actives = _active_participants(session)
|
| 488 |
for p in actives:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
# Phase 1 deliberately uses a *bare* prompt (no transcript) so each
|
| 490 |
# participant's first opinion is independent of the others.
|
| 491 |
prompt = INITIAL_OPINION_PROMPT.format(question=session.question)
|
|
@@ -525,6 +700,7 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
|
|
| 525 |
participants=_active_participants(session),
|
| 526 |
initial_opinions=session.initial_opinions,
|
| 527 |
api_log=session.api_log,
|
|
|
|
| 528 |
)
|
| 529 |
_bump_orchestrator_count(session)
|
| 530 |
session.credential_summary = creds
|
|
@@ -562,6 +738,18 @@ async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[
|
|
| 562 |
cred_block = credentials_to_block(session.credential_summary)
|
| 563 |
actives = _active_participants(session)
|
| 564 |
for p in actives:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
transcript = _format_history(session.messages)
|
| 566 |
# Snapshot pending threads BEFORE the call so we can both render
|
| 567 |
# them in the prompt and stamp them onto the outgoing message as
|
|
@@ -715,6 +903,17 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
| 715 |
announce_msg = _add_orchestrator_message(session, announce, kind="status")
|
| 716 |
yield _sse("orchestrator", _msg_payload(announce_msg))
|
| 717 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
transcript = _format_history(session.messages)
|
| 719 |
if asker is not None:
|
| 720 |
prompt2 = TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT.format(
|
|
@@ -773,6 +972,17 @@ async def _phase_finalization(session: Session) -> AsyncIterator[str]:
|
|
| 773 |
cred_block = credentials_to_block(session.credential_summary)
|
| 774 |
actives = _active_participants(session)
|
| 775 |
for p in actives:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
transcript = _format_history(session.messages)
|
| 777 |
pending = _pending_addressed_for(session, p)
|
| 778 |
pending_block = _format_pending_block(pending)
|
|
@@ -881,6 +1091,55 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
| 881 |
dyad_run = 0
|
| 882 |
last_addressed = None
|
| 883 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
# Decide allied vs solo prompt
|
| 885 |
speaker_group, other_groups = _find_speaker_group(speaker, session.alliance_groups)
|
| 886 |
prompt = _build_consensus_prompt(
|
|
@@ -1228,6 +1487,9 @@ async def run_conversation(session: Session) -> AsyncIterator[str]:
|
|
| 1228 |
finally:
|
| 1229 |
session.finished = True
|
| 1230 |
session.phase = Phase.FINISHED
|
|
|
|
|
|
|
|
|
|
| 1231 |
|
| 1232 |
# Build per-participant contribution summaries for the table view.
|
| 1233 |
try:
|
|
|
|
| 31 |
|
| 32 |
from app.clients.llm_router import chat_completion
|
| 33 |
from app.config import settings
|
| 34 |
+
from app.services import context_budget, human_io
|
| 35 |
from app.services.consensus import (
|
| 36 |
assess_consensus_status,
|
| 37 |
classify_addressed_to,
|
|
|
|
| 292 |
yield _sse("status", {"message": "Resuming conversation..."})
|
| 293 |
|
| 294 |
|
| 295 |
+
# ---------------------------------------------------------------------------
|
| 296 |
+
# Human-participant turn
|
| 297 |
+
# ---------------------------------------------------------------------------
|
| 298 |
+
|
| 299 |
+
async def _wait_for_human_text(
|
| 300 |
+
session: Session,
|
| 301 |
+
participant: Participant,
|
| 302 |
+
*,
|
| 303 |
+
phase: Phase,
|
| 304 |
+
addressed_to: str | None = None,
|
| 305 |
+
asker_id: str | None = None,
|
| 306 |
+
asker_name: str | None = None,
|
| 307 |
+
prompt_context: str | None = None,
|
| 308 |
+
) -> AsyncIterator[str]:
|
| 309 |
+
"""Pause the orchestrator until the human types a response (or skips).
|
| 310 |
+
|
| 311 |
+
Yields a `human_turn_needed` SSE event with the metadata the
|
| 312 |
+
frontend needs to render the input slot and the lower-screen
|
| 313 |
+
"waiting for your input" cue, then polls the human_io slot until
|
| 314 |
+
the API layer's POST /human-response sets it, then yields a
|
| 315 |
+
`human_turn_cleared` event so the frontend can dismiss the cue.
|
| 316 |
+
|
| 317 |
+
The actual response text + skipped flag are NOT returned from this
|
| 318 |
+
generator (async gens can't return values cleanly). The caller
|
| 319 |
+
reads them via `human_io.slot_for(session.session_id)` AFTER the
|
| 320 |
+
iteration completes:
|
| 321 |
+
|
| 322 |
+
slot.response_text (str)
|
| 323 |
+
slot.skipped (bool)
|
| 324 |
+
slot.started_at (float) - subtract from now() for elapsed
|
| 325 |
+
slot.pending_snapshot (list) - pending threads at turn-start
|
| 326 |
+
|
| 327 |
+
Caller is expected to reset_slot after consuming the result.
|
| 328 |
+
"""
|
| 329 |
+
started = time.time()
|
| 330 |
+
pending = _pending_addressed_for(session, participant)
|
| 331 |
+
slot = human_io.slot_for(session.session_id)
|
| 332 |
+
slot.event.clear()
|
| 333 |
+
slot.response_text = ""
|
| 334 |
+
slot.skipped = False
|
| 335 |
+
slot.started_at = started
|
| 336 |
+
slot.pending_snapshot = pending
|
| 337 |
+
|
| 338 |
+
awaiting = {
|
| 339 |
+
"speaker_id": participant.participant_id,
|
| 340 |
+
"speaker_name": participant.name,
|
| 341 |
+
"phase": phase.value,
|
| 342 |
+
"addressed_to": addressed_to,
|
| 343 |
+
"asker_id": asker_id,
|
| 344 |
+
"asker_name": asker_name,
|
| 345 |
+
"prompt_context": prompt_context,
|
| 346 |
+
}
|
| 347 |
+
session.awaiting_human = awaiting
|
| 348 |
+
session.paused_for_continue = True
|
| 349 |
+
session.pause_reason = "human_turn"
|
| 350 |
+
|
| 351 |
+
yield _sse("human_turn_needed", awaiting)
|
| 352 |
+
|
| 353 |
+
try:
|
| 354 |
+
# Poll with the same 0.25s cadence as _wait_for_continue so
|
| 355 |
+
# SSE-stream cancellation propagates promptly to the user
|
| 356 |
+
# clicking Stop.
|
| 357 |
+
while not slot.event.is_set():
|
| 358 |
+
await asyncio.sleep(0.25)
|
| 359 |
+
finally:
|
| 360 |
+
session.paused_for_continue = False
|
| 361 |
+
session.pause_reason = None
|
| 362 |
+
session.awaiting_human = None
|
| 363 |
+
|
| 364 |
+
yield _sse("human_turn_cleared", {
|
| 365 |
+
"speaker_id": participant.participant_id,
|
| 366 |
+
})
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
async def _do_human_turn(
|
| 370 |
+
session: Session,
|
| 371 |
+
participant: Participant,
|
| 372 |
+
*,
|
| 373 |
+
phase: Phase,
|
| 374 |
+
actives: list[Participant],
|
| 375 |
+
addressed_to_target: str | None = None,
|
| 376 |
+
asker_id: str | None = None,
|
| 377 |
+
asker_name: str | None = None,
|
| 378 |
+
prompt_context: str | None = None,
|
| 379 |
+
classify_addressed: bool = False,
|
| 380 |
+
track_initial_opinion: bool = False,
|
| 381 |
+
track_final_opinion: bool = False,
|
| 382 |
+
addressed_state: dict[str, Any] | None = None,
|
| 383 |
+
) -> AsyncIterator[str]:
|
| 384 |
+
"""End-to-end human turn: emit human_turn_needed, await response,
|
| 385 |
+
emit human_turn_cleared, then either record a skip note or append a
|
| 386 |
+
participant message (with addressed-to classification when asked).
|
| 387 |
+
Yields SSE chunks throughout, then runs the failsafe-pause check.
|
| 388 |
+
|
| 389 |
+
`addressed_state`, when provided, is a caller-owned dict that gets
|
| 390 |
+
mutated with {"last_addressed": <participant_id|None>} after the
|
| 391 |
+
turn so the consensus phase can update its routing variable
|
| 392 |
+
without a return value sneaking out of the generator.
|
| 393 |
+
"""
|
| 394 |
+
async for chunk in _wait_for_human_text(
|
| 395 |
+
session, participant, phase=phase,
|
| 396 |
+
addressed_to=addressed_to_target,
|
| 397 |
+
asker_id=asker_id, asker_name=asker_name,
|
| 398 |
+
prompt_context=prompt_context,
|
| 399 |
+
):
|
| 400 |
+
yield chunk
|
| 401 |
+
|
| 402 |
+
slot = human_io.slot_for(session.session_id)
|
| 403 |
+
text = (slot.response_text or "").strip()
|
| 404 |
+
skipped = slot.skipped
|
| 405 |
+
elapsed = max(0.0, time.time() - slot.started_at)
|
| 406 |
+
pending = list(slot.pending_snapshot or [])
|
| 407 |
+
human_io.reset_slot(session.session_id)
|
| 408 |
+
|
| 409 |
+
if skipped or not text:
|
| 410 |
+
note = _add_orchestrator_message(
|
| 411 |
+
session,
|
| 412 |
+
f"{participant.name} declined to comment this turn.",
|
| 413 |
+
kind="status",
|
| 414 |
+
)
|
| 415 |
+
yield _sse("orchestrator", _msg_payload(note))
|
| 416 |
+
if addressed_state is not None:
|
| 417 |
+
addressed_state["last_addressed"] = None
|
| 418 |
+
return
|
| 419 |
+
|
| 420 |
+
addressed: str | None = None
|
| 421 |
+
if classify_addressed:
|
| 422 |
+
addressed = await classify_addressed_to(
|
| 423 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 424 |
+
participants=actives,
|
| 425 |
+
speaker_name=participant.name,
|
| 426 |
+
message=text,
|
| 427 |
+
api_log=session.api_log,
|
| 428 |
+
)
|
| 429 |
+
_bump_orchestrator_count(session)
|
| 430 |
+
|
| 431 |
+
msg = _add_participant_message(
|
| 432 |
+
session, participant, text,
|
| 433 |
+
phase=phase, elapsed=elapsed,
|
| 434 |
+
addressed_to=addressed,
|
| 435 |
+
replying_to=_replying_to_ids(pending),
|
| 436 |
+
)
|
| 437 |
+
if track_initial_opinion:
|
| 438 |
+
session.initial_opinions[participant.participant_id] = text
|
| 439 |
+
if track_final_opinion:
|
| 440 |
+
session.final_opinions[participant.participant_id] = text
|
| 441 |
+
if addressed_state is not None:
|
| 442 |
+
addressed_state["last_addressed"] = addressed
|
| 443 |
+
|
| 444 |
+
yield _sse("message", _msg_payload(msg))
|
| 445 |
+
|
| 446 |
+
if _participant_msg_cap_hit(session):
|
| 447 |
+
async for chunk in _wait_for_continue(session, "messages"):
|
| 448 |
+
yield chunk
|
| 449 |
+
if _orchestrator_cap_hit(session):
|
| 450 |
+
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 451 |
+
yield chunk
|
| 452 |
+
|
| 453 |
+
|
| 454 |
# ---------------------------------------------------------------------------
|
| 455 |
# Participant turn (with context budgeting + summarize-on-demand)
|
| 456 |
# ---------------------------------------------------------------------------
|
|
|
|
| 589 |
"speaker_id": participant.participant_id,
|
| 590 |
"speaker_name": participant.name,
|
| 591 |
"role": "participant",
|
| 592 |
+
# `kind` lets the frontend distinguish a human participant's
|
| 593 |
+
# message ("human") from LLM messages ("neon" | "extra" |
|
| 594 |
+
# "expert") so the green left-edge accent can be applied
|
| 595 |
+
# independently of the rotating color palette.
|
| 596 |
+
"kind": participant.kind,
|
| 597 |
"text": text,
|
| 598 |
"phase": phase.value,
|
| 599 |
"timestamp": time.time(),
|
|
|
|
| 650 |
|
| 651 |
actives = _active_participants(session)
|
| 652 |
for p in actives:
|
| 653 |
+
if p.kind == "human":
|
| 654 |
+
async for chunk in _do_human_turn(
|
| 655 |
+
session, p, phase=session.phase, actives=actives,
|
| 656 |
+
track_initial_opinion=True,
|
| 657 |
+
prompt_context=(
|
| 658 |
+
"Share your initial opinion on the question. "
|
| 659 |
+
"You're speaking BEFORE seeing the other participants."
|
| 660 |
+
),
|
| 661 |
+
):
|
| 662 |
+
yield chunk
|
| 663 |
+
continue
|
| 664 |
# Phase 1 deliberately uses a *bare* prompt (no transcript) so each
|
| 665 |
# participant's first opinion is independent of the others.
|
| 666 |
prompt = INITIAL_OPINION_PROMPT.format(question=session.question)
|
|
|
|
| 700 |
participants=_active_participants(session),
|
| 701 |
initial_opinions=session.initial_opinions,
|
| 702 |
api_log=session.api_log,
|
| 703 |
+
human_credential=session.human_credential,
|
| 704 |
)
|
| 705 |
_bump_orchestrator_count(session)
|
| 706 |
session.credential_summary = creds
|
|
|
|
| 738 |
cred_block = credentials_to_block(session.credential_summary)
|
| 739 |
actives = _active_participants(session)
|
| 740 |
for p in actives:
|
| 741 |
+
if p.kind == "human":
|
| 742 |
+
async for chunk in _do_human_turn(
|
| 743 |
+
session, p, phase=session.phase, actives=actives,
|
| 744 |
+
classify_addressed=True,
|
| 745 |
+
prompt_context=(
|
| 746 |
+
f"Critique round {round_number} of {round_total}. "
|
| 747 |
+
"Push back on, agree with, or build on what others "
|
| 748 |
+
"have said. Address other participants by name."
|
| 749 |
+
),
|
| 750 |
+
):
|
| 751 |
+
yield chunk
|
| 752 |
+
continue
|
| 753 |
transcript = _format_history(session.messages)
|
| 754 |
# Snapshot pending threads BEFORE the call so we can both render
|
| 755 |
# them in the prompt and stamp them onto the outgoing message as
|
|
|
|
| 903 |
announce_msg = _add_orchestrator_message(session, announce, kind="status")
|
| 904 |
yield _sse("orchestrator", _msg_payload(announce_msg))
|
| 905 |
|
| 906 |
+
if target.kind == "human":
|
| 907 |
+
async for chunk in _do_human_turn(
|
| 908 |
+
session, target, phase=session.phase,
|
| 909 |
+
actives=_active_participants(session),
|
| 910 |
+
asker_id=(asker.participant_id if asker else None),
|
| 911 |
+
asker_name=(asker.name if asker else None),
|
| 912 |
+
prompt_context=question_text,
|
| 913 |
+
):
|
| 914 |
+
yield chunk
|
| 915 |
+
continue
|
| 916 |
+
|
| 917 |
transcript = _format_history(session.messages)
|
| 918 |
if asker is not None:
|
| 919 |
prompt2 = TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT.format(
|
|
|
|
| 972 |
cred_block = credentials_to_block(session.credential_summary)
|
| 973 |
actives = _active_participants(session)
|
| 974 |
for p in actives:
|
| 975 |
+
if p.kind == "human":
|
| 976 |
+
async for chunk in _do_human_turn(
|
| 977 |
+
session, p, phase=session.phase, actives=actives,
|
| 978 |
+
track_final_opinion=True,
|
| 979 |
+
prompt_context=(
|
| 980 |
+
"Phase 4: state your final opinion on the question, "
|
| 981 |
+
"incorporating whatever you've learned in the discussion."
|
| 982 |
+
),
|
| 983 |
+
):
|
| 984 |
+
yield chunk
|
| 985 |
+
continue
|
| 986 |
transcript = _format_history(session.messages)
|
| 987 |
pending = _pending_addressed_for(session, p)
|
| 988 |
pending_block = _format_pending_block(pending)
|
|
|
|
| 1091 |
dyad_run = 0
|
| 1092 |
last_addressed = None
|
| 1093 |
|
| 1094 |
+
if speaker.kind == "human":
|
| 1095 |
+
addressed_state: dict[str, Any] = {}
|
| 1096 |
+
async for chunk in _do_human_turn(
|
| 1097 |
+
session, speaker, phase=session.phase, actives=actives,
|
| 1098 |
+
classify_addressed=True,
|
| 1099 |
+
addressed_state=addressed_state,
|
| 1100 |
+
prompt_context=(
|
| 1101 |
+
"Phase 5: weigh in on whether you agree, disagree, "
|
| 1102 |
+
"or want to refine. Address other participants by "
|
| 1103 |
+
"name when you're responding to something specific "
|
| 1104 |
+
"they said."
|
| 1105 |
+
),
|
| 1106 |
+
):
|
| 1107 |
+
yield chunk
|
| 1108 |
+
# Propagate addressed_to so dyad routing also works when the
|
| 1109 |
+
# last speaker was the human.
|
| 1110 |
+
last_addressed = addressed_state.get("last_addressed")
|
| 1111 |
+
# Status check every full round (every len(actives) turns).
|
| 1112 |
+
# Replicated here because the LLM-path code below also does
|
| 1113 |
+
# it, and we need it on the human path too.
|
| 1114 |
+
if consensus_turns % max(1, len(actives)) == 0:
|
| 1115 |
+
transcript = _format_history(session.messages)
|
| 1116 |
+
status = await assess_consensus_status(
|
| 1117 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 1118 |
+
question=session.question,
|
| 1119 |
+
transcript=transcript,
|
| 1120 |
+
alliance_groups=session.alliance_groups,
|
| 1121 |
+
api_log=session.api_log,
|
| 1122 |
+
)
|
| 1123 |
+
_bump_orchestrator_count(session)
|
| 1124 |
+
if status.get("status") == "majority":
|
| 1125 |
+
session.alliance_groups = await _refresh_alliance_groups(session, actives)
|
| 1126 |
+
msg = _add_orchestrator_message(
|
| 1127 |
+
session,
|
| 1128 |
+
f"Majority reached. {status.get('rationale', '')}".strip(),
|
| 1129 |
+
kind="status",
|
| 1130 |
+
)
|
| 1131 |
+
yield _sse("orchestrator", _msg_payload(msg))
|
| 1132 |
+
return
|
| 1133 |
+
if status.get("status") == "unproductive":
|
| 1134 |
+
msg = _add_orchestrator_message(
|
| 1135 |
+
session,
|
| 1136 |
+
f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
|
| 1137 |
+
kind="status",
|
| 1138 |
+
)
|
| 1139 |
+
yield _sse("orchestrator", _msg_payload(msg))
|
| 1140 |
+
return
|
| 1141 |
+
continue
|
| 1142 |
+
|
| 1143 |
# Decide allied vs solo prompt
|
| 1144 |
speaker_group, other_groups = _find_speaker_group(speaker, session.alliance_groups)
|
| 1145 |
prompt = _build_consensus_prompt(
|
|
|
|
| 1487 |
finally:
|
| 1488 |
session.finished = True
|
| 1489 |
session.phase = Phase.FINISHED
|
| 1490 |
+
# Drop the human-input slot (if any) so its asyncio.Event
|
| 1491 |
+
# doesn't outlive the session in the module-level registry.
|
| 1492 |
+
human_io.drop_session(session.session_id)
|
| 1493 |
|
| 1494 |
# Build per-participant contribution summaries for the table view.
|
| 1495 |
try:
|
backend/app/services/prompts/__init__.py
CHANGED
|
@@ -36,6 +36,10 @@ from app.services.prompts.closure import (
|
|
| 36 |
CONTRIBUTION_SUMMARY_PROMPT,
|
| 37 |
)
|
| 38 |
from app.services.prompts.auto_select import AUTO_SELECT_PARTICIPANTS_PROMPT
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
__all__ = [
|
| 41 |
"PARTICIPANT_BASE_DIRECTIVE",
|
|
@@ -59,4 +63,6 @@ __all__ = [
|
|
| 59 |
"NO_CONSENSUS_REPORT_PROMPT",
|
| 60 |
"CONTRIBUTION_SUMMARY_PROMPT",
|
| 61 |
"AUTO_SELECT_PARTICIPANTS_PROMPT",
|
|
|
|
|
|
|
| 62 |
]
|
|
|
|
| 36 |
CONTRIBUTION_SUMMARY_PROMPT,
|
| 37 |
)
|
| 38 |
from app.services.prompts.auto_select import AUTO_SELECT_PARTICIPANTS_PROMPT
|
| 39 |
+
from app.services.prompts.credential_intake import (
|
| 40 |
+
CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT,
|
| 41 |
+
CREDENTIAL_INTAKE_TURN_PROMPT,
|
| 42 |
+
)
|
| 43 |
|
| 44 |
__all__ = [
|
| 45 |
"PARTICIPANT_BASE_DIRECTIVE",
|
|
|
|
| 63 |
"NO_CONSENSUS_REPORT_PROMPT",
|
| 64 |
"CONTRIBUTION_SUMMARY_PROMPT",
|
| 65 |
"AUTO_SELECT_PARTICIPANTS_PROMPT",
|
| 66 |
+
"CREDENTIAL_INTAKE_TURN_PROMPT",
|
| 67 |
+
"CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT",
|
| 68 |
]
|
backend/app/services/prompts/credential_intake.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompts for the AI-assisted Credential Summary intake.
|
| 2 |
+
|
| 3 |
+
When a user adds a human participant and clicks "Use AI to make a
|
| 4 |
+
Credential Summary", the frontend kicks off a short adaptive Q&A loop
|
| 5 |
+
backed by these prompts. On each turn the orchestrator LLM is asked
|
| 6 |
+
to either ask one more question or emit a final structured summary.
|
| 7 |
+
|
| 8 |
+
Adaptive: the LLM may wrap early if the human's answers are already
|
| 9 |
+
rich enough; it MUST wrap by the {max_questions} cap.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
# Each "turn" of the intake Q&A is a single orchestrator call returning
|
| 13 |
+
# strict JSON. The wrapper interpolates the transcript-so-far and the
|
| 14 |
+
# question/budget counters. The wrapping JSON-call helper trims any
|
| 15 |
+
# stray prose around the JSON object.
|
| 16 |
+
CREDENTIAL_INTAKE_TURN_PROMPT = """\
|
| 17 |
+
You are a friendly interviewer helping a human named "{name}" introduce
|
| 18 |
+
themselves to a group discussion about this question:
|
| 19 |
+
|
| 20 |
+
QUESTION:
|
| 21 |
+
{question}
|
| 22 |
+
|
| 23 |
+
Your job is to learn enough about this person to write a short
|
| 24 |
+
"credential summary" describing:
|
| 25 |
+
- their relevant background / expertise
|
| 26 |
+
- their personal style or perspective in discussions
|
| 27 |
+
- how credible / well-positioned they are to answer THIS question
|
| 28 |
+
- any biases or blind spots the group should be aware of
|
| 29 |
+
|
| 30 |
+
Conduct rules:
|
| 31 |
+
- You may ask up to {max_questions} short focused questions total.
|
| 32 |
+
- Ask ONE question per turn (1-2 sentences). No multi-part questions.
|
| 33 |
+
- Adapt: dig deeper on strong answers, gently restate on thin ones.
|
| 34 |
+
- Stop EARLY if you already have enough material for a useful summary.
|
| 35 |
+
- You have used {questions_asked} of {max_questions} questions so far.
|
| 36 |
+
- If {questions_asked} == {max_questions}, you MUST emit "summary"
|
| 37 |
+
on this turn rather than asking another question.
|
| 38 |
+
|
| 39 |
+
Conversation so far (the human's answers may be terse or detailed):
|
| 40 |
+
{transcript}
|
| 41 |
+
|
| 42 |
+
On THIS turn, output exactly one of the following two JSON shapes
|
| 43 |
+
(strict JSON, no commentary outside the object):
|
| 44 |
+
|
| 45 |
+
// Ask one more question:
|
| 46 |
+
{{ "kind": "question", "text": "your next short question here" }}
|
| 47 |
+
|
| 48 |
+
// Finalize - you have enough:
|
| 49 |
+
{{ "kind": "summary", "summary": {{
|
| 50 |
+
"name": "{name}",
|
| 51 |
+
"expertise": "1-2 sentences on background and what they bring",
|
| 52 |
+
"personality": "1-2 sentences on debating style or tone",
|
| 53 |
+
"credibility_for_question": 0.55,
|
| 54 |
+
"bias_to_watch": "1 sentence on biases, blind spots, or priors"
|
| 55 |
+
}} }}
|
| 56 |
+
|
| 57 |
+
credibility_for_question is a float in [0, 1]:
|
| 58 |
+
- 0.8-1.0 = clear domain expert on THIS specific question
|
| 59 |
+
- 0.5 = average familiarity, opinion is informed but not deep
|
| 60 |
+
- 0.0-0.2 = clearly outside their wheelhouse on this topic
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Phrase used when the LLM hasn't asked anything yet ({questions_asked}
|
| 65 |
+
# is 0). The orchestrator just prefills "(no answers yet)" into the
|
| 66 |
+
# transcript slot; this constant is exposed mostly for tests.
|
| 67 |
+
CREDENTIAL_INTAKE_EMPTY_TRANSCRIPT = "(no answers yet - this is the first turn)"
|
frontend/src/App.js
CHANGED
|
@@ -8,6 +8,7 @@ import ChatTableView from './components/ChatTableView';
|
|
| 8 |
import CredentialSummaryModal from './components/CredentialSummaryModal';
|
| 9 |
import ConversationLimitsModal from './components/ConversationLimitsModal';
|
| 10 |
import PromptCatalogModal from './components/PromptCatalogModal';
|
|
|
|
| 11 |
import {
|
| 12 |
fetchModels, fetchPersonas, fetchDemoQuestions,
|
| 13 |
startChat, continueChat, getOrchestrator, setOrchestrator,
|
|
@@ -17,6 +18,7 @@ import {
|
|
| 17 |
autoSelectParticipants,
|
| 18 |
fetchPromptCatalog,
|
| 19 |
getRateLimitStatus,
|
|
|
|
| 20 |
} from './utils/api';
|
| 21 |
import * as storage from './utils/storage';
|
| 22 |
import './styles/variables.css';
|
|
@@ -99,6 +101,21 @@ export default function App() {
|
|
| 99 |
const [promptCatalog, setPromptCatalog] = useState(null);
|
| 100 |
const [promptCatalogOpen, setPromptCatalogOpen] = useState(false);
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
const abortRef = useRef(null);
|
| 103 |
|
| 104 |
// ─── Apply theme ────────────────────────────────────────────────
|
|
@@ -158,11 +175,30 @@ export default function App() {
|
|
| 158 |
return map;
|
| 159 |
}, [catalog, expertPersonas]);
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
const selectedParticipants = useMemo(() => {
|
| 162 |
-
|
| 163 |
.map(id => allCatalogParticipants[id])
|
| 164 |
.filter(Boolean);
|
| 165 |
-
|
|
|
|
|
|
|
| 166 |
|
| 167 |
const enabledSelectedCount = useMemo(() => {
|
| 168 |
return selectedParticipants.filter(p => enabledMap[p.participant_id] !== false).length;
|
|
@@ -176,6 +212,7 @@ export default function App() {
|
|
| 176 |
useEffect(() => { storage.setOrchestratorModelId(orchestratorModel); }, [orchestratorModel]);
|
| 177 |
useEffect(() => { storage.setSummarizerModelId(summarizerModel); }, [summarizerModel]);
|
| 178 |
useEffect(() => { storage.setMaxParticipants(maxParticipants); }, [maxParticipants]);
|
|
|
|
| 179 |
|
| 180 |
// ─── Settings handlers ──────────────────────────────────────────
|
| 181 |
const handleOrchestratorChange = useCallback(async (modelId) => {
|
|
@@ -208,6 +245,9 @@ export default function App() {
|
|
| 208 |
// ─── Participant ops ────────────────────────────────────────────
|
| 209 |
const handleToggleParticipant = useCallback((participant, kind) => {
|
| 210 |
const id = participant.participant_id;
|
|
|
|
|
|
|
|
|
|
| 211 |
setSelectedIds(prev => {
|
| 212 |
if (prev.includes(id)) {
|
| 213 |
// Deselect entirely
|
|
@@ -218,25 +258,102 @@ export default function App() {
|
|
| 218 |
});
|
| 219 |
return prev.filter(x => x !== id);
|
| 220 |
}
|
| 221 |
-
if (prev.length >= maxParticipants) return prev;
|
| 222 |
setEnabledMap(em => ({ ...em, [id]: true }));
|
| 223 |
return [...prev, id];
|
| 224 |
});
|
| 225 |
-
}, [maxParticipants]);
|
| 226 |
|
| 227 |
const handleSidebarToggleEnabled = useCallback((participantId, enabled) => {
|
| 228 |
setEnabledMap(em => ({ ...em, [participantId]: enabled }));
|
| 229 |
}, []);
|
| 230 |
|
| 231 |
const handleSidebarRemove = useCallback((participantId) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
setSelectedIds(prev => prev.filter(x => x !== participantId));
|
| 233 |
setEnabledMap(em => {
|
| 234 |
const next = { ...em };
|
| 235 |
delete next[participantId];
|
| 236 |
return next;
|
| 237 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
}, []);
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
// ─── Auto-select toggle ─────────────────────────────────────────
|
| 241 |
// When turning ON, snapshot the current manual selection so we can
|
| 242 |
// restore it on OFF. The actual LLM ranking happens in handleStart
|
|
@@ -400,8 +517,10 @@ export default function App() {
|
|
| 400 |
kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
|
| 401 |
: (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
|
| 402 |
name: p.name,
|
| 403 |
-
role_prompt: p.role_prompt || null,
|
| 404 |
-
model_id_override:
|
|
|
|
|
|
|
| 405 |
}));
|
| 406 |
const expert_payload = baseList
|
| 407 |
.filter(p => (p.kind || '').startsWith('expert'))
|
|
@@ -411,6 +530,25 @@ export default function App() {
|
|
| 411 |
model_id: modelAssignments[p.participant_id] || p.model_id,
|
| 412 |
role_prompt: p.role_prompt,
|
| 413 |
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
return {
|
| 415 |
question: theQuestion,
|
| 416 |
participants,
|
|
@@ -421,8 +559,9 @@ export default function App() {
|
|
| 421 |
max_participants: maxParticipants,
|
| 422 |
// Sparse override map; backend clamps and falls back per-field.
|
| 423 |
limits: limitsOverrides,
|
|
|
|
| 424 |
};
|
| 425 |
-
}, [selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants, limitsOverrides]);
|
| 426 |
|
| 427 |
// ─── Stop / continue ────────────────────────────────────────────
|
| 428 |
const handleStop = useCallback(() => {
|
|
@@ -460,6 +599,7 @@ export default function App() {
|
|
| 460 |
setPause(null);
|
| 461 |
setActiveQuestion(theQuestion.trim());
|
| 462 |
setCredentialsData(null);
|
|
|
|
| 463 |
|
| 464 |
// Resolve the final participant list. When auto-select is on, ask
|
| 465 |
// the orchestrator to rank every available candidate; otherwise
|
|
@@ -485,16 +625,23 @@ export default function App() {
|
|
| 485 |
|| p.model_id || p.default_model_id || '',
|
| 486 |
}));
|
| 487 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
const result = await autoSelectParticipants({
|
| 489 |
question: theQuestion.trim(),
|
| 490 |
-
count:
|
| 491 |
candidates: candidatesPayload,
|
| 492 |
orchestrator_model_id: orchestratorModel,
|
| 493 |
});
|
| 494 |
const chosenIds = result.selected || [];
|
| 495 |
-
|
| 496 |
.map(id => allCatalogParticipants[id])
|
| 497 |
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
| 498 |
if (resolvedParticipants.length < 2) {
|
| 499 |
setIsRunning(false);
|
| 500 |
setStatusText('');
|
|
@@ -583,6 +730,18 @@ export default function App() {
|
|
| 583 |
stage: data.stage || 'built',
|
| 584 |
});
|
| 585 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
onDone: () => {
|
| 587 |
setIsRunning(false);
|
| 588 |
setStatusText('');
|
|
@@ -608,6 +767,7 @@ export default function App() {
|
|
| 608 |
buildStartPayload, enabledSelectedCount, dailyLimit,
|
| 609 |
autoSelectMode, allCatalogParticipants, modelAssignments,
|
| 610 |
maxParticipants, orchestratorModel,
|
|
|
|
| 611 |
]);
|
| 612 |
|
| 613 |
const handleStartRandom = useCallback(() => {
|
|
@@ -648,6 +808,8 @@ export default function App() {
|
|
| 648 |
onOpenExpertModal={handleOpenExpertModal}
|
| 649 |
autoSelectMode={autoSelectMode}
|
| 650 |
onToggleAutoSelectMode={handleToggleAutoSelectMode}
|
|
|
|
|
|
|
| 651 |
|
| 652 |
allModels={allModelsFlat}
|
| 653 |
orchestratorModel={orchestratorModel}
|
|
@@ -706,6 +868,10 @@ export default function App() {
|
|
| 706 |
participants={sessionParticipants.length > 0 ? sessionParticipants : selectedParticipants}
|
| 707 |
showResponseTime={showResponseTime}
|
| 708 |
showChatStats={showChatStats}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
/>
|
| 710 |
</div>
|
| 711 |
</main>
|
|
@@ -735,6 +901,17 @@ export default function App() {
|
|
| 735 |
data={credentialsData}
|
| 736 |
onClose={() => setCredentialsOpen(false)}
|
| 737 |
onRefresh={handleRefreshCredentials}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
/>
|
| 739 |
<ConversationLimitsModal
|
| 740 |
isOpen={limitsOpen}
|
|
|
|
| 8 |
import CredentialSummaryModal from './components/CredentialSummaryModal';
|
| 9 |
import ConversationLimitsModal from './components/ConversationLimitsModal';
|
| 10 |
import PromptCatalogModal from './components/PromptCatalogModal';
|
| 11 |
+
import HumanParticipantModal from './components/HumanParticipantModal';
|
| 12 |
import {
|
| 13 |
fetchModels, fetchPersonas, fetchDemoQuestions,
|
| 14 |
startChat, continueChat, getOrchestrator, setOrchestrator,
|
|
|
|
| 18 |
autoSelectParticipants,
|
| 19 |
fetchPromptCatalog,
|
| 20 |
getRateLimitStatus,
|
| 21 |
+
submitHumanResponse, patchHumanCredential,
|
| 22 |
} from './utils/api';
|
| 23 |
import * as storage from './utils/storage';
|
| 24 |
import './styles/variables.css';
|
|
|
|
| 101 |
const [promptCatalog, setPromptCatalog] = useState(null);
|
| 102 |
const [promptCatalogOpen, setPromptCatalogOpen] = useState(false);
|
| 103 |
|
| 104 |
+
// In-the-loop human participant.
|
| 105 |
+
// humanParticipant is the persisted spec:
|
| 106 |
+
// { participant_id, name, credential_summary: {...} } | null
|
| 107 |
+
// humanModalOpen / humanEditing power the Add/Edit modal.
|
| 108 |
+
// awaitingHuman holds the payload from the last human_turn_needed
|
| 109 |
+
// SSE event (null when no human turn is pending).
|
| 110 |
+
// humanSubmitting blocks the slot's buttons while POST is in flight.
|
| 111 |
+
const [humanParticipant, setHumanParticipant] = useState(
|
| 112 |
+
persisted.human_participant || null,
|
| 113 |
+
);
|
| 114 |
+
const [humanModalOpen, setHumanModalOpen] = useState(false);
|
| 115 |
+
const [humanEditing, setHumanEditing] = useState(null);
|
| 116 |
+
const [awaitingHuman, setAwaitingHuman] = useState(null);
|
| 117 |
+
const [humanSubmitting, setHumanSubmitting] = useState(false);
|
| 118 |
+
|
| 119 |
const abortRef = useRef(null);
|
| 120 |
|
| 121 |
// ─── Apply theme ────────────────────────────────────────────────
|
|
|
|
| 175 |
return map;
|
| 176 |
}, [catalog, expertPersonas]);
|
| 177 |
|
| 178 |
+
// Synthetic catalog entry for the in-the-loop human, so they slot
|
| 179 |
+
// into the same data structures the rest of the app already uses
|
| 180 |
+
// (sidebar, start payload, credentials display).
|
| 181 |
+
const humanCatalogEntry = useMemo(() => {
|
| 182 |
+
if (!humanParticipant) return null;
|
| 183 |
+
return {
|
| 184 |
+
participant_id: humanParticipant.participant_id,
|
| 185 |
+
kind: 'human',
|
| 186 |
+
name: humanParticipant.name,
|
| 187 |
+
role_prompt: '',
|
| 188 |
+
model_id: '',
|
| 189 |
+
default_model_id: '',
|
| 190 |
+
model_display: 'Human participant',
|
| 191 |
+
display_name: 'Human participant',
|
| 192 |
+
};
|
| 193 |
+
}, [humanParticipant]);
|
| 194 |
+
|
| 195 |
const selectedParticipants = useMemo(() => {
|
| 196 |
+
const fromCatalog = selectedIds
|
| 197 |
.map(id => allCatalogParticipants[id])
|
| 198 |
.filter(Boolean);
|
| 199 |
+
// The human always appears first in the sidebar / participants list.
|
| 200 |
+
return humanCatalogEntry ? [humanCatalogEntry, ...fromCatalog] : fromCatalog;
|
| 201 |
+
}, [selectedIds, allCatalogParticipants, humanCatalogEntry]);
|
| 202 |
|
| 203 |
const enabledSelectedCount = useMemo(() => {
|
| 204 |
return selectedParticipants.filter(p => enabledMap[p.participant_id] !== false).length;
|
|
|
|
| 212 |
useEffect(() => { storage.setOrchestratorModelId(orchestratorModel); }, [orchestratorModel]);
|
| 213 |
useEffect(() => { storage.setSummarizerModelId(summarizerModel); }, [summarizerModel]);
|
| 214 |
useEffect(() => { storage.setMaxParticipants(maxParticipants); }, [maxParticipants]);
|
| 215 |
+
useEffect(() => { storage.setHumanParticipant(humanParticipant); }, [humanParticipant]);
|
| 216 |
|
| 217 |
// ─── Settings handlers ──────────────────────────────────────────
|
| 218 |
const handleOrchestratorChange = useCallback(async (modelId) => {
|
|
|
|
| 245 |
// ─── Participant ops ────────────────────────────────────────────
|
| 246 |
const handleToggleParticipant = useCallback((participant, kind) => {
|
| 247 |
const id = participant.participant_id;
|
| 248 |
+
// The human occupies one of the maxParticipants slots; reserve it
|
| 249 |
+
// when computing the room left for LLM picks.
|
| 250 |
+
const humanReserved = humanParticipant ? 1 : 0;
|
| 251 |
setSelectedIds(prev => {
|
| 252 |
if (prev.includes(id)) {
|
| 253 |
// Deselect entirely
|
|
|
|
| 258 |
});
|
| 259 |
return prev.filter(x => x !== id);
|
| 260 |
}
|
| 261 |
+
if (prev.length + humanReserved >= maxParticipants) return prev;
|
| 262 |
setEnabledMap(em => ({ ...em, [id]: true }));
|
| 263 |
return [...prev, id];
|
| 264 |
});
|
| 265 |
+
}, [maxParticipants, humanParticipant]);
|
| 266 |
|
| 267 |
const handleSidebarToggleEnabled = useCallback((participantId, enabled) => {
|
| 268 |
setEnabledMap(em => ({ ...em, [participantId]: enabled }));
|
| 269 |
}, []);
|
| 270 |
|
| 271 |
const handleSidebarRemove = useCallback((participantId) => {
|
| 272 |
+
if (humanParticipant && participantId === humanParticipant.participant_id) {
|
| 273 |
+
setHumanParticipant(null);
|
| 274 |
+
return;
|
| 275 |
+
}
|
| 276 |
setSelectedIds(prev => prev.filter(x => x !== participantId));
|
| 277 |
setEnabledMap(em => {
|
| 278 |
const next = { ...em };
|
| 279 |
delete next[participantId];
|
| 280 |
return next;
|
| 281 |
});
|
| 282 |
+
}, [humanParticipant]);
|
| 283 |
+
|
| 284 |
+
// ─── Human participant ops ───────────────────────────────────────
|
| 285 |
+
const handleOpenHumanModal = useCallback(() => {
|
| 286 |
+
setHumanEditing(humanParticipant);
|
| 287 |
+
setHumanModalOpen(true);
|
| 288 |
+
}, [humanParticipant]);
|
| 289 |
+
|
| 290 |
+
const handleSaveHuman = useCallback((spec) => {
|
| 291 |
+
setHumanParticipant(spec);
|
| 292 |
+
setHumanModalOpen(false);
|
| 293 |
+
setHumanEditing(null);
|
| 294 |
+
}, []);
|
| 295 |
+
|
| 296 |
+
const handleRemoveHuman = useCallback(() => {
|
| 297 |
+
setHumanParticipant(null);
|
| 298 |
+
setHumanModalOpen(false);
|
| 299 |
+
setHumanEditing(null);
|
| 300 |
}, []);
|
| 301 |
|
| 302 |
+
const handleHumanSubmit = useCallback(async (text) => {
|
| 303 |
+
if (!sessionId || !awaitingHuman) return;
|
| 304 |
+
setHumanSubmitting(true);
|
| 305 |
+
try {
|
| 306 |
+
await submitHumanResponse(sessionId, { text });
|
| 307 |
+
} catch (err) {
|
| 308 |
+
console.error('Human response failed:', err);
|
| 309 |
+
setSystemMessages(prev => [...prev, {
|
| 310 |
+
text: `Couldn't send your message: ${err.message}`,
|
| 311 |
+
}]);
|
| 312 |
+
} finally {
|
| 313 |
+
setHumanSubmitting(false);
|
| 314 |
+
}
|
| 315 |
+
}, [sessionId, awaitingHuman]);
|
| 316 |
+
|
| 317 |
+
const handleHumanSkip = useCallback(async () => {
|
| 318 |
+
if (!sessionId || !awaitingHuman) return;
|
| 319 |
+
setHumanSubmitting(true);
|
| 320 |
+
try {
|
| 321 |
+
await submitHumanResponse(sessionId, { text: '', skip: true });
|
| 322 |
+
} catch (err) {
|
| 323 |
+
console.error('Human skip failed:', err);
|
| 324 |
+
} finally {
|
| 325 |
+
setHumanSubmitting(false);
|
| 326 |
+
}
|
| 327 |
+
}, [sessionId, awaitingHuman]);
|
| 328 |
+
|
| 329 |
+
const handleEditHumanCredential = useCallback(async (patch) => {
|
| 330 |
+
if (!sessionId) return;
|
| 331 |
+
try {
|
| 332 |
+
const result = await patchHumanCredential(sessionId, patch);
|
| 333 |
+
const updated = result.credential;
|
| 334 |
+
if (updated) {
|
| 335 |
+
// Reflect the edit in the persisted spec so re-opens of the
|
| 336 |
+
// Add-a-Human modal show the latest version.
|
| 337 |
+
setHumanParticipant(prev => prev ? {
|
| 338 |
+
...prev,
|
| 339 |
+
name: updated.name || prev.name,
|
| 340 |
+
credential_summary: {
|
| 341 |
+
name: updated.name || prev.name,
|
| 342 |
+
expertise: updated.expertise || '',
|
| 343 |
+
personality: updated.personality || '',
|
| 344 |
+
credibility_for_question: updated.credibility_for_question ?? 0.55,
|
| 345 |
+
bias_to_watch: updated.bias_to_watch || '',
|
| 346 |
+
},
|
| 347 |
+
} : prev);
|
| 348 |
+
// Refresh the credentials cache so the modal reflects the edit.
|
| 349 |
+
const data = await fetchCredentials(sessionId);
|
| 350 |
+
setCredentialsData(data);
|
| 351 |
+
}
|
| 352 |
+
} catch (err) {
|
| 353 |
+
console.error('Edit human credential failed:', err);
|
| 354 |
+
}
|
| 355 |
+
}, [sessionId]);
|
| 356 |
+
|
| 357 |
// ─── Auto-select toggle ─────────────────────────────────────────
|
| 358 |
// When turning ON, snapshot the current manual selection so we can
|
| 359 |
// restore it on OFF. The actual LLM ranking happens in handleStart
|
|
|
|
| 517 |
kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
|
| 518 |
: (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
|
| 519 |
name: p.name,
|
| 520 |
+
role_prompt: p.kind === 'human' ? null : (p.role_prompt || null),
|
| 521 |
+
model_id_override: p.kind === 'human'
|
| 522 |
+
? null
|
| 523 |
+
: (modelAssignments[p.participant_id] || null),
|
| 524 |
}));
|
| 525 |
const expert_payload = baseList
|
| 526 |
.filter(p => (p.kind || '').startsWith('expert'))
|
|
|
|
| 530 |
model_id: modelAssignments[p.participant_id] || p.model_id,
|
| 531 |
role_prompt: p.role_prompt,
|
| 532 |
}));
|
| 533 |
+
// The human's pre-authored credential summary rides alongside the
|
| 534 |
+
// participants array. Backend rejects start if it sees a human in
|
| 535 |
+
// participants but no human_credential, so this MUST be present
|
| 536 |
+
// whenever the human is enabled.
|
| 537 |
+
const humanInList = baseList.find(p => p.kind === 'human');
|
| 538 |
+
let human_credential = null;
|
| 539 |
+
if (humanInList && humanParticipant) {
|
| 540 |
+
const cs = humanParticipant.credential_summary || {};
|
| 541 |
+
human_credential = {
|
| 542 |
+
participant_id: humanInList.participant_id,
|
| 543 |
+
name: humanInList.name,
|
| 544 |
+
expertise: cs.expertise || '',
|
| 545 |
+
personality: cs.personality || '',
|
| 546 |
+
credibility_for_question: typeof cs.credibility_for_question === 'number'
|
| 547 |
+
? cs.credibility_for_question
|
| 548 |
+
: 0.55,
|
| 549 |
+
bias_to_watch: cs.bias_to_watch || '',
|
| 550 |
+
};
|
| 551 |
+
}
|
| 552 |
return {
|
| 553 |
question: theQuestion,
|
| 554 |
participants,
|
|
|
|
| 559 |
max_participants: maxParticipants,
|
| 560 |
// Sparse override map; backend clamps and falls back per-field.
|
| 561 |
limits: limitsOverrides,
|
| 562 |
+
human_credential,
|
| 563 |
};
|
| 564 |
+
}, [selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants, limitsOverrides, humanParticipant]);
|
| 565 |
|
| 566 |
// ─── Stop / continue ────────────────────────────────────────────
|
| 567 |
const handleStop = useCallback(() => {
|
|
|
|
| 599 |
setPause(null);
|
| 600 |
setActiveQuestion(theQuestion.trim());
|
| 601 |
setCredentialsData(null);
|
| 602 |
+
setAwaitingHuman(null);
|
| 603 |
|
| 604 |
// Resolve the final participant list. When auto-select is on, ask
|
| 605 |
// the orchestrator to rank every available candidate; otherwise
|
|
|
|
| 625 |
|| p.model_id || p.default_model_id || '',
|
| 626 |
}));
|
| 627 |
try {
|
| 628 |
+
// The human, if any, always gets a seat; ask the orchestrator
|
| 629 |
+
// for one fewer LLM pick so the total stays at maxParticipants.
|
| 630 |
+
const humanReserved = humanParticipant ? 1 : 0;
|
| 631 |
+
const llmTarget = Math.max(2, maxParticipants - humanReserved);
|
| 632 |
const result = await autoSelectParticipants({
|
| 633 |
question: theQuestion.trim(),
|
| 634 |
+
count: llmTarget,
|
| 635 |
candidates: candidatesPayload,
|
| 636 |
orchestrator_model_id: orchestratorModel,
|
| 637 |
});
|
| 638 |
const chosenIds = result.selected || [];
|
| 639 |
+
const chosenLlms = chosenIds
|
| 640 |
.map(id => allCatalogParticipants[id])
|
| 641 |
.filter(Boolean);
|
| 642 |
+
resolvedParticipants = humanCatalogEntry
|
| 643 |
+
? [humanCatalogEntry, ...chosenLlms]
|
| 644 |
+
: chosenLlms;
|
| 645 |
if (resolvedParticipants.length < 2) {
|
| 646 |
setIsRunning(false);
|
| 647 |
setStatusText('');
|
|
|
|
| 730 |
stage: data.stage || 'built',
|
| 731 |
});
|
| 732 |
},
|
| 733 |
+
onHumanTurnNeeded: (data) => {
|
| 734 |
+
// Orchestrator is paused waiting on the human; render the
|
| 735 |
+
// green-bordered input slot and the lower-screen indicator.
|
| 736 |
+
setAwaitingHuman(data || null);
|
| 737 |
+
setStatusText(
|
| 738 |
+
`${data?.speaker_name || 'Human'} is up next.`,
|
| 739 |
+
);
|
| 740 |
+
},
|
| 741 |
+
onHumanTurnCleared: () => {
|
| 742 |
+
setAwaitingHuman(null);
|
| 743 |
+
setHumanSubmitting(false);
|
| 744 |
+
},
|
| 745 |
onDone: () => {
|
| 746 |
setIsRunning(false);
|
| 747 |
setStatusText('');
|
|
|
|
| 767 |
buildStartPayload, enabledSelectedCount, dailyLimit,
|
| 768 |
autoSelectMode, allCatalogParticipants, modelAssignments,
|
| 769 |
maxParticipants, orchestratorModel,
|
| 770 |
+
humanParticipant, humanCatalogEntry,
|
| 771 |
]);
|
| 772 |
|
| 773 |
const handleStartRandom = useCallback(() => {
|
|
|
|
| 808 |
onOpenExpertModal={handleOpenExpertModal}
|
| 809 |
autoSelectMode={autoSelectMode}
|
| 810 |
onToggleAutoSelectMode={handleToggleAutoSelectMode}
|
| 811 |
+
humanParticipant={humanParticipant}
|
| 812 |
+
onOpenHumanModal={handleOpenHumanModal}
|
| 813 |
|
| 814 |
allModels={allModelsFlat}
|
| 815 |
orchestratorModel={orchestratorModel}
|
|
|
|
| 868 |
participants={sessionParticipants.length > 0 ? sessionParticipants : selectedParticipants}
|
| 869 |
showResponseTime={showResponseTime}
|
| 870 |
showChatStats={showChatStats}
|
| 871 |
+
awaitingHuman={awaitingHuman}
|
| 872 |
+
humanSubmitting={humanSubmitting}
|
| 873 |
+
onHumanSubmit={handleHumanSubmit}
|
| 874 |
+
onHumanSkip={handleHumanSkip}
|
| 875 |
/>
|
| 876 |
</div>
|
| 877 |
</main>
|
|
|
|
| 901 |
data={credentialsData}
|
| 902 |
onClose={() => setCredentialsOpen(false)}
|
| 903 |
onRefresh={handleRefreshCredentials}
|
| 904 |
+
humanParticipantId={humanParticipant?.participant_id || null}
|
| 905 |
+
onEditHumanCredential={handleEditHumanCredential}
|
| 906 |
+
/>
|
| 907 |
+
<HumanParticipantModal
|
| 908 |
+
isOpen={humanModalOpen}
|
| 909 |
+
initial={humanEditing}
|
| 910 |
+
question={activeQuestion}
|
| 911 |
+
orchestratorModel={orchestratorModel}
|
| 912 |
+
onClose={() => { setHumanModalOpen(false); setHumanEditing(null); }}
|
| 913 |
+
onSave={handleSaveHuman}
|
| 914 |
+
onRemove={humanEditing ? handleRemoveHuman : null}
|
| 915 |
/>
|
| 916 |
<ConversationLimitsModal
|
| 917 |
isOpen={limitsOpen}
|
frontend/src/components/ChatArea.js
CHANGED
|
@@ -2,6 +2,8 @@ import React, { useMemo } from 'react';
|
|
| 2 |
import MessageBubble from './MessageBubble';
|
| 3 |
import OrchestratorMessage from './OrchestratorMessage';
|
| 4 |
import FailsafePauseBanner from './FailsafePauseBanner';
|
|
|
|
|
|
|
| 5 |
|
| 6 |
/**
|
| 7 |
* Renders the conversation: a mix of participant bubbles, orchestrator
|
|
@@ -19,6 +21,10 @@ export default function ChatArea({
|
|
| 19 |
participants,
|
| 20 |
showResponseTime,
|
| 21 |
showChatStats,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}) {
|
| 23 |
const speakerIdxFor = useMemo(() => {
|
| 24 |
const map = {};
|
|
@@ -73,6 +79,17 @@ export default function ChatArea({
|
|
| 73 |
/>
|
| 74 |
);
|
| 75 |
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
{(systemMessages || []).map((sys, i) => (
|
| 77 |
<div
|
| 78 |
key={`sys-${i}`}
|
|
@@ -87,12 +104,13 @@ export default function ChatArea({
|
|
| 87 |
</div>
|
| 88 |
)}
|
| 89 |
<FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
|
| 90 |
-
{isRunning && statusText && (
|
| 91 |
<div className="status-bar">
|
| 92 |
<div className="spinner" />
|
| 93 |
<span>{statusText}</span>
|
| 94 |
</div>
|
| 95 |
)}
|
|
|
|
| 96 |
</div>
|
| 97 |
);
|
| 98 |
}
|
|
|
|
| 2 |
import MessageBubble from './MessageBubble';
|
| 3 |
import OrchestratorMessage from './OrchestratorMessage';
|
| 4 |
import FailsafePauseBanner from './FailsafePauseBanner';
|
| 5 |
+
import HumanInputSlot from './HumanInputSlot';
|
| 6 |
+
import HumanTurnIndicator from './HumanTurnIndicator';
|
| 7 |
|
| 8 |
/**
|
| 9 |
* Renders the conversation: a mix of participant bubbles, orchestrator
|
|
|
|
| 21 |
participants,
|
| 22 |
showResponseTime,
|
| 23 |
showChatStats,
|
| 24 |
+
awaitingHuman,
|
| 25 |
+
humanSubmitting,
|
| 26 |
+
onHumanSubmit,
|
| 27 |
+
onHumanSkip,
|
| 28 |
}) {
|
| 29 |
const speakerIdxFor = useMemo(() => {
|
| 30 |
const map = {};
|
|
|
|
| 79 |
/>
|
| 80 |
);
|
| 81 |
})}
|
| 82 |
+
{awaitingHuman && (
|
| 83 |
+
<div data-human-slot>
|
| 84 |
+
<HumanInputSlot
|
| 85 |
+
awaiting={awaitingHuman}
|
| 86 |
+
sending={humanSubmitting}
|
| 87 |
+
onSubmit={onHumanSubmit}
|
| 88 |
+
onSkip={onHumanSkip}
|
| 89 |
+
allowSkip
|
| 90 |
+
/>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
{(systemMessages || []).map((sys, i) => (
|
| 94 |
<div
|
| 95 |
key={`sys-${i}`}
|
|
|
|
| 104 |
</div>
|
| 105 |
)}
|
| 106 |
<FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
|
| 107 |
+
{isRunning && statusText && !awaitingHuman && (
|
| 108 |
<div className="status-bar">
|
| 109 |
<div className="spinner" />
|
| 110 |
<span>{statusText}</span>
|
| 111 |
</div>
|
| 112 |
)}
|
| 113 |
+
<HumanTurnIndicator awaiting={awaitingHuman} />
|
| 114 |
</div>
|
| 115 |
);
|
| 116 |
}
|
frontend/src/components/CredentialSummaryModal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
import React, { useMemo } from 'react';
|
| 2 |
-
import { Download } from 'lucide-react';
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Read-only modal that surfaces the orchestrator-generated Credential
|
|
@@ -19,6 +19,8 @@ export default function CredentialSummaryModal({
|
|
| 19 |
data,
|
| 20 |
onClose,
|
| 21 |
onRefresh,
|
|
|
|
|
|
|
| 22 |
}) {
|
| 23 |
// Hooks must run on every render, so the filename memo lives ABOVE
|
| 24 |
// the early return. The dependency on `isOpen` regenerates the
|
|
@@ -99,9 +101,18 @@ export default function CredentialSummaryModal({
|
|
| 99 |
orchestrator builds it after Phase 1 (initial opinions).
|
| 100 |
</div>
|
| 101 |
) : (
|
| 102 |
-
credentials.map((c) =>
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
)}
|
| 106 |
</div>
|
| 107 |
</div>
|
|
@@ -109,13 +120,112 @@ export default function CredentialSummaryModal({
|
|
| 109 |
);
|
| 110 |
}
|
| 111 |
|
| 112 |
-
function CredentialCard({ cred }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
const score = toScore(cred.credibility_for_question);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
return (
|
| 115 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<div className="ccai-credential-card-head">
|
| 117 |
<div className="ccai-credential-name">
|
|
|
|
|
|
|
|
|
|
| 118 |
{cred.name || cred.participant_id}
|
|
|
|
|
|
|
|
|
|
| 119 |
</div>
|
| 120 |
{score !== null && (
|
| 121 |
<div className="ccai-credibility-wrap" title={`Credibility ${score.toFixed(2)} of 1.0`}>
|
|
@@ -129,6 +239,17 @@ function CredentialCard({ cred }) {
|
|
| 129 |
<span className="ccai-credibility-num">{score.toFixed(2)}</span>
|
| 130 |
</div>
|
| 131 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
</div>
|
| 133 |
<FieldRow label="Expertise" value={cred.expertise} />
|
| 134 |
<FieldRow label="Style" value={cred.personality} />
|
|
@@ -137,6 +258,40 @@ function CredentialCard({ cred }) {
|
|
| 137 |
);
|
| 138 |
}
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
function FieldRow({ label, value }) {
|
| 141 |
if (!value) return null;
|
| 142 |
return (
|
|
|
|
| 1 |
+
import React, { useMemo, useState, useEffect } from 'react';
|
| 2 |
+
import { Download, Edit2, Check, X, User } from 'lucide-react';
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Read-only modal that surfaces the orchestrator-generated Credential
|
|
|
|
| 19 |
data,
|
| 20 |
onClose,
|
| 21 |
onRefresh,
|
| 22 |
+
humanParticipantId,
|
| 23 |
+
onEditHumanCredential,
|
| 24 |
}) {
|
| 25 |
// Hooks must run on every render, so the filename memo lives ABOVE
|
| 26 |
// the early return. The dependency on `isOpen` regenerates the
|
|
|
|
| 101 |
orchestrator builds it after Phase 1 (initial opinions).
|
| 102 |
</div>
|
| 103 |
) : (
|
| 104 |
+
credentials.map((c) => {
|
| 105 |
+
const isHuman = !!humanParticipantId
|
| 106 |
+
&& c.participant_id === humanParticipantId;
|
| 107 |
+
return (
|
| 108 |
+
<CredentialCard
|
| 109 |
+
key={c.participant_id}
|
| 110 |
+
cred={c}
|
| 111 |
+
isHuman={isHuman}
|
| 112 |
+
onEdit={isHuman ? onEditHumanCredential : null}
|
| 113 |
+
/>
|
| 114 |
+
);
|
| 115 |
+
})
|
| 116 |
)}
|
| 117 |
</div>
|
| 118 |
</div>
|
|
|
|
| 120 |
);
|
| 121 |
}
|
| 122 |
|
| 123 |
+
function CredentialCard({ cred, isHuman, onEdit }) {
|
| 124 |
+
const [editing, setEditing] = useState(false);
|
| 125 |
+
const [draft, setDraft] = useState(() => ({
|
| 126 |
+
name: cred.name || '',
|
| 127 |
+
expertise: cred.expertise || '',
|
| 128 |
+
personality: cred.personality || '',
|
| 129 |
+
credibility_for_question:
|
| 130 |
+
cred.credibility_for_question !== undefined
|
| 131 |
+
? cred.credibility_for_question
|
| 132 |
+
: 0.5,
|
| 133 |
+
bias_to_watch: cred.bias_to_watch || '',
|
| 134 |
+
}));
|
| 135 |
+
|
| 136 |
+
// Reset the draft whenever the underlying credential payload
|
| 137 |
+
// changes (e.g. a Phase-3 refresh from the SSE stream).
|
| 138 |
+
useEffect(() => {
|
| 139 |
+
setDraft({
|
| 140 |
+
name: cred.name || '',
|
| 141 |
+
expertise: cred.expertise || '',
|
| 142 |
+
personality: cred.personality || '',
|
| 143 |
+
credibility_for_question:
|
| 144 |
+
cred.credibility_for_question !== undefined
|
| 145 |
+
? cred.credibility_for_question
|
| 146 |
+
: 0.5,
|
| 147 |
+
bias_to_watch: cred.bias_to_watch || '',
|
| 148 |
+
});
|
| 149 |
+
}, [cred]);
|
| 150 |
+
|
| 151 |
const score = toScore(cred.credibility_for_question);
|
| 152 |
+
|
| 153 |
+
if (isHuman && editing) {
|
| 154 |
+
return (
|
| 155 |
+
<div className="ccai-credential-card ccai-credential-card-human ccai-credential-card-editing">
|
| 156 |
+
<div className="ccai-credential-card-head">
|
| 157 |
+
<div className="ccai-credential-name">
|
| 158 |
+
<User size={14} style={{ marginRight: 4, verticalAlign: '-2px' }} />
|
| 159 |
+
<input
|
| 160 |
+
className="ccai-credential-edit-name"
|
| 161 |
+
type="text"
|
| 162 |
+
value={draft.name}
|
| 163 |
+
onChange={e => setDraft(d => ({ ...d, name: e.target.value }))}
|
| 164 |
+
/>
|
| 165 |
+
<span className="ccai-credential-human-tag">Human</span>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
<EditableRow
|
| 169 |
+
label="Expertise"
|
| 170 |
+
value={draft.expertise}
|
| 171 |
+
onChange={v => setDraft(d => ({ ...d, expertise: v }))}
|
| 172 |
+
/>
|
| 173 |
+
<EditableRow
|
| 174 |
+
label="Style"
|
| 175 |
+
value={draft.personality}
|
| 176 |
+
onChange={v => setDraft(d => ({ ...d, personality: v }))}
|
| 177 |
+
/>
|
| 178 |
+
<EditableScoreRow
|
| 179 |
+
label="Credibility (0-1)"
|
| 180 |
+
value={draft.credibility_for_question}
|
| 181 |
+
onChange={v => setDraft(d => ({ ...d, credibility_for_question: v }))}
|
| 182 |
+
/>
|
| 183 |
+
<EditableRow
|
| 184 |
+
label="Bias to watch"
|
| 185 |
+
value={draft.bias_to_watch}
|
| 186 |
+
onChange={v => setDraft(d => ({ ...d, bias_to_watch: v }))}
|
| 187 |
+
/>
|
| 188 |
+
<div className="ccai-credential-edit-actions">
|
| 189 |
+
<button
|
| 190 |
+
type="button"
|
| 191 |
+
className="btn-sm btn-outline"
|
| 192 |
+
onClick={() => setEditing(false)}
|
| 193 |
+
>
|
| 194 |
+
<X size={12} style={{ marginRight: 4 }} />
|
| 195 |
+
Cancel
|
| 196 |
+
</button>
|
| 197 |
+
<button
|
| 198 |
+
type="button"
|
| 199 |
+
className="btn btn-primary btn-sm"
|
| 200 |
+
onClick={async () => {
|
| 201 |
+
await onEdit?.(draft);
|
| 202 |
+
setEditing(false);
|
| 203 |
+
}}
|
| 204 |
+
>
|
| 205 |
+
<Check size={12} style={{ marginRight: 4 }} />
|
| 206 |
+
Save
|
| 207 |
+
</button>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
return (
|
| 214 |
+
<div
|
| 215 |
+
className={
|
| 216 |
+
'ccai-credential-card'
|
| 217 |
+
+ (isHuman ? ' ccai-credential-card-human' : '')
|
| 218 |
+
}
|
| 219 |
+
>
|
| 220 |
<div className="ccai-credential-card-head">
|
| 221 |
<div className="ccai-credential-name">
|
| 222 |
+
{isHuman && (
|
| 223 |
+
<User size={14} style={{ marginRight: 4, verticalAlign: '-2px' }} />
|
| 224 |
+
)}
|
| 225 |
{cred.name || cred.participant_id}
|
| 226 |
+
{isHuman && (
|
| 227 |
+
<span className="ccai-credential-human-tag">Human</span>
|
| 228 |
+
)}
|
| 229 |
</div>
|
| 230 |
{score !== null && (
|
| 231 |
<div className="ccai-credibility-wrap" title={`Credibility ${score.toFixed(2)} of 1.0`}>
|
|
|
|
| 239 |
<span className="ccai-credibility-num">{score.toFixed(2)}</span>
|
| 240 |
</div>
|
| 241 |
)}
|
| 242 |
+
{isHuman && onEdit && (
|
| 243 |
+
<button
|
| 244 |
+
type="button"
|
| 245 |
+
className="btn-sm btn-outline ccai-credential-edit-btn"
|
| 246 |
+
onClick={() => setEditing(true)}
|
| 247 |
+
title="Edit your credential summary"
|
| 248 |
+
>
|
| 249 |
+
<Edit2 size={12} style={{ marginRight: 4 }} />
|
| 250 |
+
Edit
|
| 251 |
+
</button>
|
| 252 |
+
)}
|
| 253 |
</div>
|
| 254 |
<FieldRow label="Expertise" value={cred.expertise} />
|
| 255 |
<FieldRow label="Style" value={cred.personality} />
|
|
|
|
| 258 |
);
|
| 259 |
}
|
| 260 |
|
| 261 |
+
function EditableRow({ label, value, onChange }) {
|
| 262 |
+
return (
|
| 263 |
+
<div className="ccai-credential-row ccai-credential-row-edit">
|
| 264 |
+
<div className="ccai-credential-row-label">{label}</div>
|
| 265 |
+
<textarea
|
| 266 |
+
className="ccai-credential-row-input"
|
| 267 |
+
rows={2}
|
| 268 |
+
value={value}
|
| 269 |
+
onChange={e => onChange(e.target.value)}
|
| 270 |
+
/>
|
| 271 |
+
</div>
|
| 272 |
+
);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function EditableScoreRow({ label, value, onChange }) {
|
| 276 |
+
return (
|
| 277 |
+
<div className="ccai-credential-row ccai-credential-row-edit">
|
| 278 |
+
<div className="ccai-credential-row-label">{label}</div>
|
| 279 |
+
<input
|
| 280 |
+
type="number"
|
| 281 |
+
min={0}
|
| 282 |
+
max={1}
|
| 283 |
+
step={0.05}
|
| 284 |
+
value={value}
|
| 285 |
+
className="ccai-credential-row-input ccai-credential-row-input-num"
|
| 286 |
+
onChange={(e) => {
|
| 287 |
+
const v = parseFloat(e.target.value);
|
| 288 |
+
if (!Number.isNaN(v)) onChange(Math.max(0, Math.min(1, v)));
|
| 289 |
+
}}
|
| 290 |
+
/>
|
| 291 |
+
</div>
|
| 292 |
+
);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
function FieldRow({ label, value }) {
|
| 296 |
if (!value) return null;
|
| 297 |
return (
|
frontend/src/components/Header.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import React from 'react';
|
|
|
|
| 2 |
import AuthBadge from './AuthBadge';
|
| 3 |
import ParticipantDropdown from './ParticipantDropdown';
|
| 4 |
import DownloadMenu from './DownloadMenu';
|
|
@@ -32,6 +33,8 @@ export default function Header({
|
|
| 32 |
onOpenExpertModal,
|
| 33 |
autoSelectMode,
|
| 34 |
onToggleAutoSelectMode,
|
|
|
|
|
|
|
| 35 |
|
| 36 |
// Models / display
|
| 37 |
allModels,
|
|
@@ -89,6 +92,29 @@ export default function Header({
|
|
| 89 |
autoSelectMode={autoSelectMode}
|
| 90 |
onToggleAutoSelectMode={onToggleAutoSelectMode}
|
| 91 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
<DownloadMenu
|
| 93 |
hasChat={hasChat}
|
| 94 |
hasApiLog={hasApiLog}
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { UserPlus, UserCheck } from 'lucide-react';
|
| 3 |
import AuthBadge from './AuthBadge';
|
| 4 |
import ParticipantDropdown from './ParticipantDropdown';
|
| 5 |
import DownloadMenu from './DownloadMenu';
|
|
|
|
| 33 |
onOpenExpertModal,
|
| 34 |
autoSelectMode,
|
| 35 |
onToggleAutoSelectMode,
|
| 36 |
+
humanParticipant,
|
| 37 |
+
onOpenHumanModal,
|
| 38 |
|
| 39 |
// Models / display
|
| 40 |
allModels,
|
|
|
|
| 92 |
autoSelectMode={autoSelectMode}
|
| 93 |
onToggleAutoSelectMode={onToggleAutoSelectMode}
|
| 94 |
/>
|
| 95 |
+
<button
|
| 96 |
+
type="button"
|
| 97 |
+
className={
|
| 98 |
+
'btn-sm btn-outline ccai-human-add-btn'
|
| 99 |
+
+ (humanParticipant ? ' ccai-human-add-btn-active' : '')
|
| 100 |
+
}
|
| 101 |
+
onClick={onOpenHumanModal}
|
| 102 |
+
title={humanParticipant
|
| 103 |
+
? `Edit ${humanParticipant.name}'s credential summary`
|
| 104 |
+
: 'Add a human participant to the conversation'}
|
| 105 |
+
>
|
| 106 |
+
{humanParticipant ? (
|
| 107 |
+
<>
|
| 108 |
+
<UserCheck size={14} style={{ marginRight: 4 }} />
|
| 109 |
+
Human: {humanParticipant.name}
|
| 110 |
+
</>
|
| 111 |
+
) : (
|
| 112 |
+
<>
|
| 113 |
+
<UserPlus size={14} style={{ marginRight: 4 }} />
|
| 114 |
+
Add a Human Participant
|
| 115 |
+
</>
|
| 116 |
+
)}
|
| 117 |
+
</button>
|
| 118 |
<DownloadMenu
|
| 119 |
hasChat={hasChat}
|
| 120 |
hasApiLog={hasApiLog}
|
frontend/src/components/HumanInputSlot.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Send, SkipForward } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Inline input slot rendered in the chat stream when it's the human
|
| 6 |
+
* participant's turn. Replaces what would otherwise be the LLM's
|
| 7 |
+
* message bubble. Visually distinct via a thick green left-edge
|
| 8 |
+
* accent and a pulsing border so it's obvious "we're waiting on you".
|
| 9 |
+
*
|
| 10 |
+
* Props:
|
| 11 |
+
* awaiting - the awaiting_human payload from the most recent
|
| 12 |
+
* human_turn_needed SSE event:
|
| 13 |
+
* { speaker_id, speaker_name, phase,
|
| 14 |
+
* addressed_to?, asker_name?, prompt_context? }
|
| 15 |
+
* onSubmit - async (text) => void
|
| 16 |
+
* onSkip - async () => void (only when allowSkip)
|
| 17 |
+
* allowSkip - bool, defaults true
|
| 18 |
+
* sending - bool: disable the buttons while a submit is in flight
|
| 19 |
+
*/
|
| 20 |
+
export default function HumanInputSlot({
|
| 21 |
+
awaiting,
|
| 22 |
+
onSubmit,
|
| 23 |
+
onSkip,
|
| 24 |
+
allowSkip = true,
|
| 25 |
+
sending = false,
|
| 26 |
+
}) {
|
| 27 |
+
const [text, setText] = useState('');
|
| 28 |
+
const taRef = useRef(null);
|
| 29 |
+
|
| 30 |
+
// Auto-focus when the slot first appears, so the user can start
|
| 31 |
+
// typing immediately without hunting for the textarea.
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
if (awaiting && taRef.current) {
|
| 34 |
+
taRef.current.focus();
|
| 35 |
+
}
|
| 36 |
+
// We intentionally narrow the dep list to the identity of the
|
| 37 |
+
// pending turn (speaker + phase). Re-focusing on every awaiting
|
| 38 |
+
// mutation would steal focus from the user mid-type.
|
| 39 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 40 |
+
}, [awaiting?.speaker_id, awaiting?.phase]);
|
| 41 |
+
|
| 42 |
+
if (!awaiting) return null;
|
| 43 |
+
|
| 44 |
+
const name = awaiting.speaker_name || 'you';
|
| 45 |
+
const askerLine = awaiting.asker_name
|
| 46 |
+
? `${awaiting.asker_name} asked: ${awaiting.prompt_context || '…'}`
|
| 47 |
+
: awaiting.prompt_context || '';
|
| 48 |
+
|
| 49 |
+
const handleSubmit = async () => {
|
| 50 |
+
const value = text.trim();
|
| 51 |
+
if (!value) return;
|
| 52 |
+
await onSubmit?.(value);
|
| 53 |
+
setText('');
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleKeyDown = (e) => {
|
| 57 |
+
// Ctrl/Cmd+Enter submits; plain Enter inserts a newline (textarea default).
|
| 58 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 59 |
+
e.preventDefault();
|
| 60 |
+
handleSubmit();
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="ccai-human-slot" data-speaker-id={awaiting.speaker_id || ''}>
|
| 66 |
+
<div className="ccai-human-slot-accent" />
|
| 67 |
+
<div className="ccai-human-slot-body">
|
| 68 |
+
<div className="ccai-human-slot-header">
|
| 69 |
+
<span className="ccai-human-slot-name">{name}</span>
|
| 70 |
+
<span className="ccai-human-slot-pulse" aria-hidden="true" />
|
| 71 |
+
<span className="ccai-human-slot-prompt">
|
| 72 |
+
{name}, please type your response here.
|
| 73 |
+
</span>
|
| 74 |
+
</div>
|
| 75 |
+
{askerLine && (
|
| 76 |
+
<div className="ccai-human-slot-context">{askerLine}</div>
|
| 77 |
+
)}
|
| 78 |
+
<textarea
|
| 79 |
+
ref={taRef}
|
| 80 |
+
className="ccai-human-slot-textarea"
|
| 81 |
+
value={text}
|
| 82 |
+
onChange={e => setText(e.target.value)}
|
| 83 |
+
onKeyDown={handleKeyDown}
|
| 84 |
+
rows={4}
|
| 85 |
+
placeholder={`${name} please type your response here`}
|
| 86 |
+
disabled={sending}
|
| 87 |
+
/>
|
| 88 |
+
<div className="ccai-human-slot-actions">
|
| 89 |
+
<span className="ccai-human-slot-hint">
|
| 90 |
+
Ctrl+Enter to submit
|
| 91 |
+
</span>
|
| 92 |
+
<div className="ccai-human-slot-actions-right">
|
| 93 |
+
{allowSkip && (
|
| 94 |
+
<button
|
| 95 |
+
type="button"
|
| 96 |
+
className="btn-sm btn-outline ccai-human-slot-skip"
|
| 97 |
+
onClick={() => onSkip?.()}
|
| 98 |
+
disabled={sending}
|
| 99 |
+
title="Skip my turn this round"
|
| 100 |
+
>
|
| 101 |
+
<SkipForward size={14} style={{ marginRight: 4 }} />
|
| 102 |
+
Skip my turn
|
| 103 |
+
</button>
|
| 104 |
+
)}
|
| 105 |
+
<button
|
| 106 |
+
type="button"
|
| 107 |
+
className="btn btn-primary btn-sm ccai-human-slot-submit"
|
| 108 |
+
onClick={handleSubmit}
|
| 109 |
+
disabled={sending || !text.trim()}
|
| 110 |
+
>
|
| 111 |
+
<Send size={14} style={{ marginRight: 4 }} />
|
| 112 |
+
{sending ? 'Sending…' : 'Submit'}
|
| 113 |
+
</button>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
frontend/src/components/HumanParticipantModal.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
| 2 |
+
import { Download, Sparkles, X } from 'lucide-react';
|
| 3 |
+
import {
|
| 4 |
+
startCredentialDraft,
|
| 5 |
+
answerCredentialDraft,
|
| 6 |
+
cancelCredentialDraft,
|
| 7 |
+
} from '../utils/api';
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Modal for adding (or editing) the in-the-loop human participant.
|
| 11 |
+
*
|
| 12 |
+
* Two flows:
|
| 13 |
+
* - Manual: the user types their own credential summary into the
|
| 14 |
+
* text area. The textarea pre-fills with a sample so they always
|
| 15 |
+
* have somewhere to start.
|
| 16 |
+
* - AI-assisted: clicking "Use AI to make a Credential Summary"
|
| 17 |
+
* opens a small chat with the orchestrator LLM. The LLM asks 3-6
|
| 18 |
+
* adaptive questions and emits a final structured summary, which
|
| 19 |
+
* replaces the textarea content.
|
| 20 |
+
*
|
| 21 |
+
* On Approve, the modal hands the finalized
|
| 22 |
+
* { participant_id, name, credential_summary: {...} }
|
| 23 |
+
* shape back to App.js via onSave. App.js persists it via storage and
|
| 24 |
+
* adds the human to the active participant set.
|
| 25 |
+
*
|
| 26 |
+
* "Download as .txt" lets the user keep a copy of their summary
|
| 27 |
+
* outside the demo (useful if they want to reuse it elsewhere).
|
| 28 |
+
*/
|
| 29 |
+
export default function HumanParticipantModal({
|
| 30 |
+
isOpen,
|
| 31 |
+
initial,
|
| 32 |
+
question,
|
| 33 |
+
orchestratorModel,
|
| 34 |
+
onClose,
|
| 35 |
+
onSave,
|
| 36 |
+
onRemove,
|
| 37 |
+
}) {
|
| 38 |
+
const [name, setName] = useState('');
|
| 39 |
+
const [summary, setSummary] = useState('');
|
| 40 |
+
// AI Q&A state
|
| 41 |
+
const [aiDraftId, setAiDraftId] = useState(null);
|
| 42 |
+
const [aiHistory, setAiHistory] = useState([]); // [{q, a}, ...]
|
| 43 |
+
const [aiCurrentQuestion, setAiCurrentQuestion] = useState('');
|
| 44 |
+
const [aiAnswer, setAiAnswer] = useState('');
|
| 45 |
+
const [aiBusy, setAiBusy] = useState(false);
|
| 46 |
+
const [aiError, setAiError] = useState('');
|
| 47 |
+
const [aiCounts, setAiCounts] = useState({ asked: 0, max: 6 });
|
| 48 |
+
|
| 49 |
+
const sampleRef = useRef('');
|
| 50 |
+
|
| 51 |
+
// Reset on open / when the initial payload changes.
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
if (!isOpen) return;
|
| 54 |
+
const initialName = initial?.name || 'Pat';
|
| 55 |
+
setName(initialName);
|
| 56 |
+
const sample = initial?.credential_summary
|
| 57 |
+
? renderSummaryToText(initial.credential_summary)
|
| 58 |
+
: sampleSummaryText(initialName);
|
| 59 |
+
sampleRef.current = sample;
|
| 60 |
+
setSummary(sample);
|
| 61 |
+
setAiDraftId(null);
|
| 62 |
+
setAiHistory([]);
|
| 63 |
+
setAiCurrentQuestion('');
|
| 64 |
+
setAiAnswer('');
|
| 65 |
+
setAiBusy(false);
|
| 66 |
+
setAiError('');
|
| 67 |
+
setAiCounts({ asked: 0, max: 6 });
|
| 68 |
+
}, [isOpen, initial]);
|
| 69 |
+
|
| 70 |
+
// Abandon the AI Q&A if the modal is closed mid-flow.
|
| 71 |
+
useEffect(() => {
|
| 72 |
+
if (!isOpen && aiDraftId) {
|
| 73 |
+
cancelCredentialDraft(aiDraftId);
|
| 74 |
+
}
|
| 75 |
+
}, [isOpen, aiDraftId]);
|
| 76 |
+
|
| 77 |
+
const handleStartAi = useCallback(async () => {
|
| 78 |
+
if (!name.trim()) {
|
| 79 |
+
setAiError('Please enter a name first.');
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
if (!question || !question.trim()) {
|
| 83 |
+
setAiError('Enter your discussion question before using AI assist.');
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
setAiBusy(true);
|
| 87 |
+
setAiError('');
|
| 88 |
+
try {
|
| 89 |
+
const result = await startCredentialDraft({
|
| 90 |
+
name: name.trim(),
|
| 91 |
+
question: question.trim(),
|
| 92 |
+
max_questions: 6,
|
| 93 |
+
orchestrator_model_id: orchestratorModel || null,
|
| 94 |
+
});
|
| 95 |
+
setAiDraftId(result.draft_id);
|
| 96 |
+
setAiCounts({
|
| 97 |
+
asked: result.questions_asked || 1,
|
| 98 |
+
max: result.max_questions || 6,
|
| 99 |
+
});
|
| 100 |
+
if (result.kind === 'summary') {
|
| 101 |
+
setSummary(renderSummaryToText({
|
| 102 |
+
...(result.summary || {}),
|
| 103 |
+
name: result.summary?.name || name.trim(),
|
| 104 |
+
}));
|
| 105 |
+
setAiCurrentQuestion('');
|
| 106 |
+
} else {
|
| 107 |
+
setAiCurrentQuestion(result.question || '');
|
| 108 |
+
}
|
| 109 |
+
} catch (err) {
|
| 110 |
+
setAiError(err.message || 'AI assist failed to start.');
|
| 111 |
+
} finally {
|
| 112 |
+
setAiBusy(false);
|
| 113 |
+
}
|
| 114 |
+
}, [name, question, orchestratorModel]);
|
| 115 |
+
|
| 116 |
+
const handleSubmitAnswer = useCallback(async () => {
|
| 117 |
+
if (!aiDraftId) return;
|
| 118 |
+
if (!aiAnswer.trim()) {
|
| 119 |
+
setAiError('Please type an answer first.');
|
| 120 |
+
return;
|
| 121 |
+
}
|
| 122 |
+
setAiBusy(true);
|
| 123 |
+
setAiError('');
|
| 124 |
+
const lastQ = aiCurrentQuestion;
|
| 125 |
+
const lastA = aiAnswer.trim();
|
| 126 |
+
try {
|
| 127 |
+
const result = await answerCredentialDraft(aiDraftId, lastA);
|
| 128 |
+
setAiHistory(prev => [...prev, { q: lastQ, a: lastA }]);
|
| 129 |
+
setAiAnswer('');
|
| 130 |
+
setAiCounts({
|
| 131 |
+
asked: result.questions_asked || aiCounts.asked,
|
| 132 |
+
max: result.max_questions || aiCounts.max,
|
| 133 |
+
});
|
| 134 |
+
if (result.kind === 'summary') {
|
| 135 |
+
setSummary(renderSummaryToText({
|
| 136 |
+
...(result.summary || {}),
|
| 137 |
+
name: result.summary?.name || name.trim(),
|
| 138 |
+
}));
|
| 139 |
+
setAiCurrentQuestion('');
|
| 140 |
+
setAiDraftId(null);
|
| 141 |
+
} else {
|
| 142 |
+
setAiCurrentQuestion(result.question || '');
|
| 143 |
+
}
|
| 144 |
+
} catch (err) {
|
| 145 |
+
setAiError(err.message || 'AI assist failed to continue.');
|
| 146 |
+
} finally {
|
| 147 |
+
setAiBusy(false);
|
| 148 |
+
}
|
| 149 |
+
}, [aiDraftId, aiAnswer, aiCurrentQuestion, aiCounts.asked, aiCounts.max, name]);
|
| 150 |
+
|
| 151 |
+
const handleStopAi = useCallback(async () => {
|
| 152 |
+
if (aiDraftId) {
|
| 153 |
+
await cancelCredentialDraft(aiDraftId);
|
| 154 |
+
}
|
| 155 |
+
setAiDraftId(null);
|
| 156 |
+
setAiCurrentQuestion('');
|
| 157 |
+
setAiAnswer('');
|
| 158 |
+
setAiError('');
|
| 159 |
+
}, [aiDraftId]);
|
| 160 |
+
|
| 161 |
+
const handleApprove = useCallback(() => {
|
| 162 |
+
if (!name.trim()) {
|
| 163 |
+
setAiError('Please enter a name before approving.');
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
if (!summary.trim()) {
|
| 167 |
+
setAiError('Credential summary cannot be empty.');
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
const parsed = parseSummaryText(summary, name.trim());
|
| 171 |
+
const pid = initial?.participant_id || `human_${Date.now()}`;
|
| 172 |
+
onSave({
|
| 173 |
+
participant_id: pid,
|
| 174 |
+
name: name.trim(),
|
| 175 |
+
credential_summary: parsed,
|
| 176 |
+
});
|
| 177 |
+
}, [name, summary, initial, onSave]);
|
| 178 |
+
|
| 179 |
+
const handleDownload = useCallback(() => {
|
| 180 |
+
const text = `Name: ${name}\n\n${summary}\n`;
|
| 181 |
+
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
| 182 |
+
const url = URL.createObjectURL(blob);
|
| 183 |
+
const a = document.createElement('a');
|
| 184 |
+
a.href = url;
|
| 185 |
+
a.download = `${(name || 'human').replace(/\s+/g, '_')}-credential.txt`;
|
| 186 |
+
a.click();
|
| 187 |
+
URL.revokeObjectURL(url);
|
| 188 |
+
}, [name, summary]);
|
| 189 |
+
|
| 190 |
+
if (!isOpen) return null;
|
| 191 |
+
|
| 192 |
+
const aiInProgress = !!aiDraftId && !!aiCurrentQuestion;
|
| 193 |
+
|
| 194 |
+
return (
|
| 195 |
+
<div className="ccai-credentials-overlay">
|
| 196 |
+
<div className="ccai-credentials-card ccai-human-modal-card">
|
| 197 |
+
<div className="ccai-credentials-header">
|
| 198 |
+
<div>
|
| 199 |
+
<h2>Add a Human Participant</h2>
|
| 200 |
+
<div className="ccai-credentials-subtitle">
|
| 201 |
+
Give yourself (or another human) a seat at the table.
|
| 202 |
+
The orchestrator will pause for your input when it's
|
| 203 |
+
your turn.
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div className="ccai-tab-spacer" />
|
| 207 |
+
<button className="modal-close" onClick={onClose}>×</button>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div className="ccai-human-modal-body">
|
| 211 |
+
<label className="ccai-human-field">
|
| 212 |
+
<span className="ccai-human-field-label">Name</span>
|
| 213 |
+
<input
|
| 214 |
+
type="text"
|
| 215 |
+
className="ccai-human-input"
|
| 216 |
+
value={name}
|
| 217 |
+
onChange={e => setName(e.target.value)}
|
| 218 |
+
placeholder="e.g. Pat, Dr. Lopez, …"
|
| 219 |
+
/>
|
| 220 |
+
</label>
|
| 221 |
+
|
| 222 |
+
<div className="ccai-human-field">
|
| 223 |
+
<div className="ccai-human-summary-header">
|
| 224 |
+
<span className="ccai-human-field-label">
|
| 225 |
+
Credential summary
|
| 226 |
+
</span>
|
| 227 |
+
<button
|
| 228 |
+
type="button"
|
| 229 |
+
className="btn-sm btn-outline ccai-human-ai-btn"
|
| 230 |
+
onClick={handleStartAi}
|
| 231 |
+
disabled={aiBusy || aiInProgress}
|
| 232 |
+
title="Have the AI ask a few questions and draft a summary for you"
|
| 233 |
+
>
|
| 234 |
+
<Sparkles size={14} style={{ marginRight: 4 }} />
|
| 235 |
+
Use AI to make a Credential Summary
|
| 236 |
+
</button>
|
| 237 |
+
</div>
|
| 238 |
+
<textarea
|
| 239 |
+
className="ccai-human-summary"
|
| 240 |
+
value={summary}
|
| 241 |
+
onChange={e => setSummary(e.target.value)}
|
| 242 |
+
rows={10}
|
| 243 |
+
spellCheck
|
| 244 |
+
/>
|
| 245 |
+
<div className="ccai-human-summary-help">
|
| 246 |
+
This is what the orchestrator and other participants will
|
| 247 |
+
see about you. Edit it to your taste — or click the AI
|
| 248 |
+
assist button above and answer a few questions.
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
{aiInProgress && (
|
| 253 |
+
<div className="ccai-human-ai-panel">
|
| 254 |
+
<div className="ccai-human-ai-counter">
|
| 255 |
+
AI assist · question {aiCounts.asked} of {aiCounts.max}
|
| 256 |
+
<button
|
| 257 |
+
type="button"
|
| 258 |
+
className="ccai-human-ai-stop"
|
| 259 |
+
onClick={handleStopAi}
|
| 260 |
+
title="Stop the AI Q&A and keep what's in the textarea"
|
| 261 |
+
>
|
| 262 |
+
<X size={12} /> Stop
|
| 263 |
+
</button>
|
| 264 |
+
</div>
|
| 265 |
+
{aiHistory.map((qa, i) => (
|
| 266 |
+
<div key={i} className="ccai-human-ai-turn">
|
| 267 |
+
<div className="ccai-human-ai-q">Q: {qa.q}</div>
|
| 268 |
+
<div className="ccai-human-ai-a">A: {qa.a}</div>
|
| 269 |
+
</div>
|
| 270 |
+
))}
|
| 271 |
+
<div className="ccai-human-ai-current-q">
|
| 272 |
+
<strong>Q:</strong> {aiCurrentQuestion}
|
| 273 |
+
</div>
|
| 274 |
+
<textarea
|
| 275 |
+
className="ccai-human-ai-answer"
|
| 276 |
+
value={aiAnswer}
|
| 277 |
+
onChange={e => setAiAnswer(e.target.value)}
|
| 278 |
+
rows={3}
|
| 279 |
+
placeholder="Your answer..."
|
| 280 |
+
disabled={aiBusy}
|
| 281 |
+
/>
|
| 282 |
+
<div className="ccai-human-ai-actions">
|
| 283 |
+
<button
|
| 284 |
+
type="button"
|
| 285 |
+
className="btn btn-primary btn-sm"
|
| 286 |
+
onClick={handleSubmitAnswer}
|
| 287 |
+
disabled={aiBusy || !aiAnswer.trim()}
|
| 288 |
+
>
|
| 289 |
+
{aiBusy ? 'Thinking…' : 'Send answer'}
|
| 290 |
+
</button>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
|
| 295 |
+
{aiError && (
|
| 296 |
+
<div className="ccai-human-error">{aiError}</div>
|
| 297 |
+
)}
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<div className="ccai-human-modal-footer">
|
| 301 |
+
<div>
|
| 302 |
+
{onRemove && initial?.participant_id && (
|
| 303 |
+
<button
|
| 304 |
+
type="button"
|
| 305 |
+
className="btn-sm btn-outline ccai-human-remove"
|
| 306 |
+
onClick={onRemove}
|
| 307 |
+
title="Remove the human participant from this session"
|
| 308 |
+
>
|
| 309 |
+
<X size={14} style={{ marginRight: 4 }} />
|
| 310 |
+
Remove human
|
| 311 |
+
</button>
|
| 312 |
+
)}
|
| 313 |
+
</div>
|
| 314 |
+
<div className="ccai-human-modal-footer-right">
|
| 315 |
+
<button
|
| 316 |
+
type="button"
|
| 317 |
+
className="btn-sm btn-outline"
|
| 318 |
+
onClick={handleDownload}
|
| 319 |
+
disabled={!summary.trim()}
|
| 320 |
+
>
|
| 321 |
+
<Download size={14} style={{ marginRight: 4 }} />
|
| 322 |
+
Download as .txt
|
| 323 |
+
</button>
|
| 324 |
+
<button type="button" className="btn-sm btn-outline" onClick={onClose}>
|
| 325 |
+
Cancel
|
| 326 |
+
</button>
|
| 327 |
+
<button
|
| 328 |
+
type="button"
|
| 329 |
+
className="btn btn-primary btn-sm"
|
| 330 |
+
onClick={handleApprove}
|
| 331 |
+
disabled={!name.trim() || !summary.trim()}
|
| 332 |
+
>
|
| 333 |
+
Approve
|
| 334 |
+
</button>
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// ─── Helpers ─────────────────────────────────────────────────────
|
| 343 |
+
|
| 344 |
+
function sampleSummaryText(name) {
|
| 345 |
+
// The pre-fill is intentionally generic-but-plausible: the user can
|
| 346 |
+
// edit a sentence or two and approve, or wipe it and start fresh.
|
| 347 |
+
return [
|
| 348 |
+
`Expertise: ${name} is a curious generalist with hands-on `
|
| 349 |
+
+ 'experience across several professional domains, comfortable '
|
| 350 |
+
+ 'asking pointed questions in unfamiliar territory.',
|
| 351 |
+
'',
|
| 352 |
+
`Style: Conversational and pragmatic; ${name} weighs trade-offs `
|
| 353 |
+
+ 'aloud and is happy to change their mind when shown new '
|
| 354 |
+
+ 'evidence.',
|
| 355 |
+
'',
|
| 356 |
+
'Credibility on this question: 0.55',
|
| 357 |
+
'',
|
| 358 |
+
'Bias to watch: Tendency to favor concrete, near-term solutions '
|
| 359 |
+
+ 'over abstract long-horizon ones.',
|
| 360 |
+
].join('\n');
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function renderSummaryToText(cred) {
|
| 364 |
+
if (!cred) return '';
|
| 365 |
+
const lines = [];
|
| 366 |
+
if (cred.expertise) lines.push(`Expertise: ${cred.expertise}`);
|
| 367 |
+
if (cred.personality) lines.push('', `Style: ${cred.personality}`);
|
| 368 |
+
if (cred.credibility_for_question !== undefined
|
| 369 |
+
&& cred.credibility_for_question !== null) {
|
| 370 |
+
const v = Number(cred.credibility_for_question);
|
| 371 |
+
if (!Number.isNaN(v)) {
|
| 372 |
+
lines.push('', `Credibility on this question: ${v.toFixed(2)}`);
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
if (cred.bias_to_watch) lines.push('', `Bias to watch: ${cred.bias_to_watch}`);
|
| 376 |
+
return lines.join('\n');
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/**
|
| 380 |
+
* Parse the textarea content back into the structured shape the
|
| 381 |
+
* backend expects. Looks for lines starting with the field labels;
|
| 382 |
+
* anything else is appended to whichever field is current.
|
| 383 |
+
*
|
| 384 |
+
* This is tolerant: if no labels are found, the whole blob becomes
|
| 385 |
+
* the `expertise` field (so naive users typing freeform still get a
|
| 386 |
+
* usable credential summary on Approve).
|
| 387 |
+
*/
|
| 388 |
+
function parseSummaryText(text, name) {
|
| 389 |
+
const result = {
|
| 390 |
+
name,
|
| 391 |
+
expertise: '',
|
| 392 |
+
personality: '',
|
| 393 |
+
credibility_for_question: 0.55,
|
| 394 |
+
bias_to_watch: '',
|
| 395 |
+
};
|
| 396 |
+
let current = 'expertise';
|
| 397 |
+
for (const rawLine of text.split('\n')) {
|
| 398 |
+
const line = rawLine.trimEnd();
|
| 399 |
+
if (!line.trim()) continue;
|
| 400 |
+
const lower = line.toLowerCase();
|
| 401 |
+
if (lower.startsWith('expertise:')) {
|
| 402 |
+
current = 'expertise';
|
| 403 |
+
result.expertise = line.slice(line.indexOf(':') + 1).trim();
|
| 404 |
+
} else if (lower.startsWith('style:') || lower.startsWith('personality:')) {
|
| 405 |
+
current = 'personality';
|
| 406 |
+
result.personality = line.slice(line.indexOf(':') + 1).trim();
|
| 407 |
+
} else if (lower.startsWith('credibility')) {
|
| 408 |
+
current = 'credibility';
|
| 409 |
+
const num = parseFloat(line.replace(/[^0-9.]/g, ''));
|
| 410 |
+
if (!Number.isNaN(num)) {
|
| 411 |
+
// Heuristic: numbers > 1 are probably 0..100; coerce to 0..1.
|
| 412 |
+
result.credibility_for_question = num > 1 ? num / 100 : num;
|
| 413 |
+
}
|
| 414 |
+
} else if (lower.startsWith('bias')) {
|
| 415 |
+
current = 'bias_to_watch';
|
| 416 |
+
result.bias_to_watch = line.slice(line.indexOf(':') + 1).trim();
|
| 417 |
+
} else if (current === 'expertise') {
|
| 418 |
+
result.expertise += (result.expertise ? '\n' : '') + line;
|
| 419 |
+
} else if (current === 'personality') {
|
| 420 |
+
result.personality += (result.personality ? '\n' : '') + line;
|
| 421 |
+
} else if (current === 'bias_to_watch') {
|
| 422 |
+
result.bias_to_watch += (result.bias_to_watch ? '\n' : '') + line;
|
| 423 |
+
}
|
| 424 |
+
}
|
| 425 |
+
// Clamp credibility into the valid range.
|
| 426 |
+
if (Number.isNaN(result.credibility_for_question)) {
|
| 427 |
+
result.credibility_for_question = 0.55;
|
| 428 |
+
}
|
| 429 |
+
result.credibility_for_question = Math.max(
|
| 430 |
+
0, Math.min(1, result.credibility_for_question),
|
| 431 |
+
);
|
| 432 |
+
return result;
|
| 433 |
+
}
|
frontend/src/components/HumanTurnIndicator.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { ArrowDown } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Fixed-position attention cue rendered along the bottom edge of the
|
| 6 |
+
* viewport when the orchestrator is waiting for the human's input.
|
| 7 |
+
*
|
| 8 |
+
* Per the spec we do NOT auto-scroll - the user might be reading
|
| 9 |
+
* earlier messages and resent the page jumping under them. We just
|
| 10 |
+
* surface a persistent green pulse + arrow + name so they can scroll
|
| 11 |
+
* down to the input slot on their own when ready.
|
| 12 |
+
*
|
| 13 |
+
* Clicking the indicator scrolls the input slot into view (best
|
| 14 |
+
* effort: we look for [data-human-slot] in the DOM).
|
| 15 |
+
*/
|
| 16 |
+
export default function HumanTurnIndicator({ awaiting }) {
|
| 17 |
+
if (!awaiting) return null;
|
| 18 |
+
const name = awaiting.speaker_name || 'you';
|
| 19 |
+
const handleClick = () => {
|
| 20 |
+
const target = document.querySelector('[data-human-slot]');
|
| 21 |
+
if (target) {
|
| 22 |
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 23 |
+
const ta = target.querySelector('textarea');
|
| 24 |
+
if (ta) ta.focus({ preventScroll: true });
|
| 25 |
+
}
|
| 26 |
+
};
|
| 27 |
+
return (
|
| 28 |
+
<button
|
| 29 |
+
type="button"
|
| 30 |
+
className="ccai-human-indicator"
|
| 31 |
+
onClick={handleClick}
|
| 32 |
+
title={`Jump to ${name}'s input slot`}
|
| 33 |
+
>
|
| 34 |
+
<ArrowDown
|
| 35 |
+
size={16}
|
| 36 |
+
strokeWidth={2.5}
|
| 37 |
+
className="ccai-human-indicator-arrow"
|
| 38 |
+
/>
|
| 39 |
+
<span className="ccai-human-indicator-text">
|
| 40 |
+
{name}, the discussion is waiting for your input
|
| 41 |
+
</span>
|
| 42 |
+
</button>
|
| 43 |
+
);
|
| 44 |
+
}
|
frontend/src/components/MessageBubble.js
CHANGED
|
@@ -35,6 +35,11 @@ function colorForIdx(idx) {
|
|
| 35 |
* the immediately previous bubble - cheap visual threading without
|
| 36 |
* full nesting.
|
| 37 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
export default function MessageBubble({
|
| 39 |
message,
|
| 40 |
idx,
|
|
@@ -43,7 +48,9 @@ export default function MessageBubble({
|
|
| 43 |
participantNameById,
|
| 44 |
showResponseTime,
|
| 45 |
}) {
|
| 46 |
-
const
|
|
|
|
|
|
|
| 47 |
const initial = (message.speaker_name || '?').charAt(0).toUpperCase();
|
| 48 |
const elapsed = message.elapsed_seconds;
|
| 49 |
|
|
@@ -90,7 +97,8 @@ export default function MessageBubble({
|
|
| 90 |
|
| 91 |
const rowClassName =
|
| 92 |
'message-row ccai-message-row' +
|
| 93 |
-
(isDirectReply ? ' ccai-message-row-reply' : '')
|
|
|
|
| 94 |
|
| 95 |
return (
|
| 96 |
<div
|
|
|
|
| 35 |
* the immediately previous bubble - cheap visual threading without
|
| 36 |
* full nesting.
|
| 37 |
*/
|
| 38 |
+
// Green tone used for in-the-loop human participants. Overrides the
|
| 39 |
+
// rotating palette so a human's bubble always reads as "the human
|
| 40 |
+
// one" no matter where they fall in the active roster ordering.
|
| 41 |
+
const HUMAN_TONE = { color: '#16A34A', bg: '#F0FDF4' };
|
| 42 |
+
|
| 43 |
export default function MessageBubble({
|
| 44 |
message,
|
| 45 |
idx,
|
|
|
|
| 48 |
participantNameById,
|
| 49 |
showResponseTime,
|
| 50 |
}) {
|
| 51 |
+
const isHuman = message.kind === 'human'
|
| 52 |
+
|| (message.model_display === 'Human participant');
|
| 53 |
+
const tone = isHuman ? HUMAN_TONE : colorForIdx(idx);
|
| 54 |
const initial = (message.speaker_name || '?').charAt(0).toUpperCase();
|
| 55 |
const elapsed = message.elapsed_seconds;
|
| 56 |
|
|
|
|
| 97 |
|
| 98 |
const rowClassName =
|
| 99 |
'message-row ccai-message-row' +
|
| 100 |
+
(isDirectReply ? ' ccai-message-row-reply' : '') +
|
| 101 |
+
(isHuman ? ' ccai-message-row-human' : '');
|
| 102 |
|
| 103 |
return (
|
| 104 |
<div
|
frontend/src/components/ParticipantSidebar.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
-
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Replaces LLMChats3's LLMSelector. Lists the user's currently selected
|
|
@@ -67,11 +67,14 @@ export default function ParticipantSidebar({
|
|
| 67 |
|
| 68 |
function ParticipantCard({ participant, enabled, modelOverride, onToggleEnabled, onRemove }) {
|
| 69 |
const [open, setOpen] = useState(false);
|
|
|
|
| 70 |
|
| 71 |
return (
|
| 72 |
<div
|
| 73 |
className={
|
| 74 |
-
'ccai-participant-card'
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
>
|
| 77 |
<div className="ccai-participant-row">
|
|
@@ -82,7 +85,19 @@ function ParticipantCard({ participant, enabled, modelOverride, onToggleEnabled,
|
|
| 82 |
>
|
| 83 |
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 84 |
</button>
|
| 85 |
-
<div className="ccai-participant-name">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
<div className="ccai-participant-controls">
|
| 87 |
{enabled ? (
|
| 88 |
<label className="ccai-toggle" title="Toggle participation">
|
|
@@ -116,18 +131,32 @@ function ParticipantCard({ participant, enabled, modelOverride, onToggleEnabled,
|
|
| 116 |
)}
|
| 117 |
{open && (
|
| 118 |
<div className="ccai-participant-body">
|
| 119 |
-
|
| 120 |
-
<div className="ccai-participant-field
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</div>
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
)}
|
| 133 |
</div>
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { ChevronDown, ChevronRight, User, X } from 'lucide-react';
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Replaces LLMChats3's LLMSelector. Lists the user's currently selected
|
|
|
|
| 67 |
|
| 68 |
function ParticipantCard({ participant, enabled, modelOverride, onToggleEnabled, onRemove }) {
|
| 69 |
const [open, setOpen] = useState(false);
|
| 70 |
+
const isHuman = participant.kind === 'human';
|
| 71 |
|
| 72 |
return (
|
| 73 |
<div
|
| 74 |
className={
|
| 75 |
+
'ccai-participant-card'
|
| 76 |
+
+ (enabled ? '' : ' ccai-participant-card-off')
|
| 77 |
+
+ (isHuman ? ' ccai-participant-card-human' : '')
|
| 78 |
}
|
| 79 |
>
|
| 80 |
<div className="ccai-participant-row">
|
|
|
|
| 85 |
>
|
| 86 |
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 87 |
</button>
|
| 88 |
+
<div className="ccai-participant-name">
|
| 89 |
+
{isHuman && (
|
| 90 |
+
<User
|
| 91 |
+
size={12}
|
| 92 |
+
strokeWidth={2.5}
|
| 93 |
+
style={{ marginRight: 4, verticalAlign: '-2px' }}
|
| 94 |
+
/>
|
| 95 |
+
)}
|
| 96 |
+
{participant.name}
|
| 97 |
+
{isHuman && (
|
| 98 |
+
<span className="ccai-participant-human-tag">Human</span>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
<div className="ccai-participant-controls">
|
| 102 |
{enabled ? (
|
| 103 |
<label className="ccai-toggle" title="Toggle participation">
|
|
|
|
| 131 |
)}
|
| 132 |
{open && (
|
| 133 |
<div className="ccai-participant-body">
|
| 134 |
+
{isHuman ? (
|
| 135 |
+
<div className="ccai-participant-field">
|
| 136 |
+
<div className="ccai-participant-field-label">Role</div>
|
| 137 |
+
<div className="ccai-participant-field-value">
|
| 138 |
+
In-the-loop human participant. The orchestrator pauses
|
| 139 |
+
for your input when it's your turn. Edit your name and
|
| 140 |
+
credential summary from the "Human: …" button in
|
| 141 |
+
the header.
|
| 142 |
+
</div>
|
| 143 |
</div>
|
| 144 |
+
) : (
|
| 145 |
+
<>
|
| 146 |
+
<div className="ccai-participant-field">
|
| 147 |
+
<div className="ccai-participant-field-label">LLM</div>
|
| 148 |
+
<div className="ccai-participant-field-value">
|
| 149 |
+
{modelOverride || participant.default_model_id || participant.model_display || ''}
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
<div className="ccai-participant-field">
|
| 153 |
+
<div className="ccai-participant-field-label">Persona prompt</div>
|
| 154 |
+
<pre className="ccai-participant-prompt">
|
| 155 |
+
{participant.role_prompt || '(no prompt set)'}
|
| 156 |
+
</pre>
|
| 157 |
+
</div>
|
| 158 |
+
</>
|
| 159 |
+
)}
|
| 160 |
</div>
|
| 161 |
)}
|
| 162 |
</div>
|
frontend/src/styles/ccai.css
CHANGED
|
@@ -1288,3 +1288,547 @@
|
|
| 1288 |
max-height: 280px;
|
| 1289 |
overflow-y: auto;
|
| 1290 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
max-height: 280px;
|
| 1289 |
overflow-y: auto;
|
| 1290 |
}
|
| 1291 |
+
|
| 1292 |
+
|
| 1293 |
+
/* ════════════════════════════════════════════════════════════════
|
| 1294 |
+
Human Participant
|
| 1295 |
+
Styles for the in-the-loop human: add/edit modal, sidebar entry,
|
| 1296 |
+
in-chat input slot, lower-screen attention cue, green-accent bubble,
|
| 1297 |
+
and the inline edit affordance on the human's credential row.
|
| 1298 |
+
════════════════════════════════════════════════════════════════ */
|
| 1299 |
+
|
| 1300 |
+
/* ── Add a Human Participant button (header) ───────────────────── */
|
| 1301 |
+
|
| 1302 |
+
.ccai-human-add-btn {
|
| 1303 |
+
display: inline-flex;
|
| 1304 |
+
align-items: center;
|
| 1305 |
+
gap: 4px;
|
| 1306 |
+
white-space: nowrap;
|
| 1307 |
+
}
|
| 1308 |
+
|
| 1309 |
+
.ccai-human-add-btn-active {
|
| 1310 |
+
border-color: #16A34A;
|
| 1311 |
+
color: #16A34A;
|
| 1312 |
+
}
|
| 1313 |
+
|
| 1314 |
+
[data-theme="dark"] .ccai-human-add-btn-active {
|
| 1315 |
+
border-color: #4ADE80;
|
| 1316 |
+
color: #4ADE80;
|
| 1317 |
+
}
|
| 1318 |
+
|
| 1319 |
+
/* ── Human Participant modal (Add / Edit) ──────────────────────── */
|
| 1320 |
+
|
| 1321 |
+
.ccai-human-modal-card {
|
| 1322 |
+
width: min(640px, 92vw);
|
| 1323 |
+
}
|
| 1324 |
+
|
| 1325 |
+
.ccai-human-modal-body {
|
| 1326 |
+
display: flex;
|
| 1327 |
+
flex-direction: column;
|
| 1328 |
+
gap: 16px;
|
| 1329 |
+
padding: 16px 20px;
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
.ccai-human-field {
|
| 1333 |
+
display: flex;
|
| 1334 |
+
flex-direction: column;
|
| 1335 |
+
gap: 6px;
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
.ccai-human-field-label {
|
| 1339 |
+
font-size: 12px;
|
| 1340 |
+
font-weight: 600;
|
| 1341 |
+
color: var(--text-secondary);
|
| 1342 |
+
text-transform: uppercase;
|
| 1343 |
+
letter-spacing: 0.04em;
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
.ccai-human-input {
|
| 1347 |
+
padding: 8px 10px;
|
| 1348 |
+
border: 1px solid var(--border-primary);
|
| 1349 |
+
border-radius: 6px;
|
| 1350 |
+
background: var(--card-bg);
|
| 1351 |
+
color: var(--text-primary);
|
| 1352 |
+
font-size: 14px;
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
.ccai-human-input:focus {
|
| 1356 |
+
outline: none;
|
| 1357 |
+
border-color: #16A34A;
|
| 1358 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
.ccai-human-summary-header {
|
| 1362 |
+
display: flex;
|
| 1363 |
+
align-items: center;
|
| 1364 |
+
justify-content: space-between;
|
| 1365 |
+
gap: 8px;
|
| 1366 |
+
}
|
| 1367 |
+
|
| 1368 |
+
.ccai-human-summary {
|
| 1369 |
+
width: 100%;
|
| 1370 |
+
padding: 10px 12px;
|
| 1371 |
+
border: 1px solid var(--border-primary);
|
| 1372 |
+
border-radius: 6px;
|
| 1373 |
+
background: var(--card-bg);
|
| 1374 |
+
color: var(--text-primary);
|
| 1375 |
+
font-size: 13px;
|
| 1376 |
+
font-family: inherit;
|
| 1377 |
+
line-height: 1.5;
|
| 1378 |
+
resize: vertical;
|
| 1379 |
+
min-height: 160px;
|
| 1380 |
+
box-sizing: border-box;
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
.ccai-human-summary:focus {
|
| 1384 |
+
outline: none;
|
| 1385 |
+
border-color: #16A34A;
|
| 1386 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1387 |
+
}
|
| 1388 |
+
|
| 1389 |
+
.ccai-human-summary-help {
|
| 1390 |
+
font-size: 12px;
|
| 1391 |
+
color: var(--text-tertiary);
|
| 1392 |
+
font-style: italic;
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
.ccai-human-ai-btn {
|
| 1396 |
+
display: inline-flex;
|
| 1397 |
+
align-items: center;
|
| 1398 |
+
gap: 4px;
|
| 1399 |
+
white-space: nowrap;
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
.ccai-human-ai-panel {
|
| 1403 |
+
border: 1px solid #16A34A;
|
| 1404 |
+
background: rgba(22, 163, 74, 0.04);
|
| 1405 |
+
border-radius: 8px;
|
| 1406 |
+
padding: 12px 14px;
|
| 1407 |
+
display: flex;
|
| 1408 |
+
flex-direction: column;
|
| 1409 |
+
gap: 10px;
|
| 1410 |
+
}
|
| 1411 |
+
|
| 1412 |
+
[data-theme="dark"] .ccai-human-ai-panel {
|
| 1413 |
+
background: rgba(74, 222, 128, 0.06);
|
| 1414 |
+
border-color: #4ADE80;
|
| 1415 |
+
}
|
| 1416 |
+
|
| 1417 |
+
.ccai-human-ai-counter {
|
| 1418 |
+
display: flex;
|
| 1419 |
+
align-items: center;
|
| 1420 |
+
justify-content: space-between;
|
| 1421 |
+
font-size: 12px;
|
| 1422 |
+
font-weight: 600;
|
| 1423 |
+
color: #16A34A;
|
| 1424 |
+
text-transform: uppercase;
|
| 1425 |
+
letter-spacing: 0.04em;
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
[data-theme="dark"] .ccai-human-ai-counter {
|
| 1429 |
+
color: #4ADE80;
|
| 1430 |
+
}
|
| 1431 |
+
|
| 1432 |
+
.ccai-human-ai-stop {
|
| 1433 |
+
display: inline-flex;
|
| 1434 |
+
align-items: center;
|
| 1435 |
+
gap: 4px;
|
| 1436 |
+
background: transparent;
|
| 1437 |
+
border: 1px solid var(--border-primary);
|
| 1438 |
+
border-radius: 4px;
|
| 1439 |
+
padding: 2px 6px;
|
| 1440 |
+
font-size: 11px;
|
| 1441 |
+
color: var(--text-secondary);
|
| 1442 |
+
cursor: pointer;
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
.ccai-human-ai-stop:hover {
|
| 1446 |
+
border-color: var(--text-secondary);
|
| 1447 |
+
color: var(--text-primary);
|
| 1448 |
+
}
|
| 1449 |
+
|
| 1450 |
+
.ccai-human-ai-turn {
|
| 1451 |
+
display: flex;
|
| 1452 |
+
flex-direction: column;
|
| 1453 |
+
gap: 2px;
|
| 1454 |
+
font-size: 12.5px;
|
| 1455 |
+
padding-bottom: 6px;
|
| 1456 |
+
border-bottom: 1px dashed var(--border-primary);
|
| 1457 |
+
}
|
| 1458 |
+
|
| 1459 |
+
.ccai-human-ai-q {
|
| 1460 |
+
color: var(--text-secondary);
|
| 1461 |
+
font-style: italic;
|
| 1462 |
+
}
|
| 1463 |
+
|
| 1464 |
+
.ccai-human-ai-a {
|
| 1465 |
+
color: var(--text-primary);
|
| 1466 |
+
}
|
| 1467 |
+
|
| 1468 |
+
.ccai-human-ai-current-q {
|
| 1469 |
+
font-size: 13px;
|
| 1470 |
+
color: var(--text-primary);
|
| 1471 |
+
background: var(--card-bg);
|
| 1472 |
+
border-left: 3px solid #16A34A;
|
| 1473 |
+
padding: 6px 10px;
|
| 1474 |
+
border-radius: 4px;
|
| 1475 |
+
}
|
| 1476 |
+
|
| 1477 |
+
.ccai-human-ai-answer {
|
| 1478 |
+
width: 100%;
|
| 1479 |
+
border: 1px solid var(--border-primary);
|
| 1480 |
+
border-radius: 6px;
|
| 1481 |
+
background: var(--card-bg);
|
| 1482 |
+
color: var(--text-primary);
|
| 1483 |
+
font-family: inherit;
|
| 1484 |
+
font-size: 13px;
|
| 1485 |
+
padding: 8px 10px;
|
| 1486 |
+
box-sizing: border-box;
|
| 1487 |
+
resize: vertical;
|
| 1488 |
+
}
|
| 1489 |
+
|
| 1490 |
+
.ccai-human-ai-answer:focus {
|
| 1491 |
+
outline: none;
|
| 1492 |
+
border-color: #16A34A;
|
| 1493 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1494 |
+
}
|
| 1495 |
+
|
| 1496 |
+
.ccai-human-ai-actions {
|
| 1497 |
+
display: flex;
|
| 1498 |
+
justify-content: flex-end;
|
| 1499 |
+
}
|
| 1500 |
+
|
| 1501 |
+
.ccai-human-error {
|
| 1502 |
+
font-size: 12.5px;
|
| 1503 |
+
color: #DC2626;
|
| 1504 |
+
background: rgba(220, 38, 38, 0.08);
|
| 1505 |
+
border: 1px solid rgba(220, 38, 38, 0.3);
|
| 1506 |
+
border-radius: 6px;
|
| 1507 |
+
padding: 6px 10px;
|
| 1508 |
+
}
|
| 1509 |
+
|
| 1510 |
+
.ccai-human-modal-footer {
|
| 1511 |
+
display: flex;
|
| 1512 |
+
align-items: center;
|
| 1513 |
+
justify-content: space-between;
|
| 1514 |
+
gap: 8px;
|
| 1515 |
+
padding: 12px 20px 16px 20px;
|
| 1516 |
+
border-top: 1px solid var(--border-primary);
|
| 1517 |
+
}
|
| 1518 |
+
|
| 1519 |
+
.ccai-human-modal-footer-right {
|
| 1520 |
+
display: flex;
|
| 1521 |
+
align-items: center;
|
| 1522 |
+
gap: 8px;
|
| 1523 |
+
}
|
| 1524 |
+
|
| 1525 |
+
.ccai-human-remove {
|
| 1526 |
+
color: #DC2626;
|
| 1527 |
+
border-color: rgba(220, 38, 38, 0.4);
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
.ccai-human-remove:hover {
|
| 1531 |
+
background: rgba(220, 38, 38, 0.06);
|
| 1532 |
+
border-color: #DC2626;
|
| 1533 |
+
}
|
| 1534 |
+
|
| 1535 |
+
/* ── Sidebar: Human participant card ──────────────────────────── */
|
| 1536 |
+
|
| 1537 |
+
.ccai-participant-card-human {
|
| 1538 |
+
border-left: 4px solid #16A34A;
|
| 1539 |
+
}
|
| 1540 |
+
|
| 1541 |
+
[data-theme="dark"] .ccai-participant-card-human {
|
| 1542 |
+
border-left-color: #4ADE80;
|
| 1543 |
+
}
|
| 1544 |
+
|
| 1545 |
+
.ccai-participant-human-tag {
|
| 1546 |
+
display: inline-block;
|
| 1547 |
+
margin-left: 8px;
|
| 1548 |
+
padding: 1px 6px;
|
| 1549 |
+
font-size: 10px;
|
| 1550 |
+
font-weight: 600;
|
| 1551 |
+
text-transform: uppercase;
|
| 1552 |
+
letter-spacing: 0.04em;
|
| 1553 |
+
color: #16A34A;
|
| 1554 |
+
background: rgba(22, 163, 74, 0.1);
|
| 1555 |
+
border-radius: 4px;
|
| 1556 |
+
}
|
| 1557 |
+
|
| 1558 |
+
[data-theme="dark"] .ccai-participant-human-tag {
|
| 1559 |
+
color: #4ADE80;
|
| 1560 |
+
background: rgba(74, 222, 128, 0.12);
|
| 1561 |
+
}
|
| 1562 |
+
|
| 1563 |
+
/* ── In-chat human input slot ─────────────────────────────────── */
|
| 1564 |
+
|
| 1565 |
+
.ccai-human-slot {
|
| 1566 |
+
display: flex;
|
| 1567 |
+
align-items: stretch;
|
| 1568 |
+
margin: 16px 0;
|
| 1569 |
+
border-radius: 10px;
|
| 1570 |
+
background: var(--card-bg);
|
| 1571 |
+
border: 2px solid #16A34A;
|
| 1572 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.12);
|
| 1573 |
+
overflow: hidden;
|
| 1574 |
+
animation: ccai-human-slot-pop 0.25s ease-out;
|
| 1575 |
+
}
|
| 1576 |
+
|
| 1577 |
+
@keyframes ccai-human-slot-pop {
|
| 1578 |
+
from { transform: scale(0.985); opacity: 0; }
|
| 1579 |
+
to { transform: scale(1); opacity: 1; }
|
| 1580 |
+
}
|
| 1581 |
+
|
| 1582 |
+
.ccai-human-slot-accent {
|
| 1583 |
+
width: 6px;
|
| 1584 |
+
background: #16A34A;
|
| 1585 |
+
flex-shrink: 0;
|
| 1586 |
+
animation: ccai-human-pulse-accent 1.6s ease-in-out infinite;
|
| 1587 |
+
}
|
| 1588 |
+
|
| 1589 |
+
@keyframes ccai-human-pulse-accent {
|
| 1590 |
+
0% { background: #16A34A; }
|
| 1591 |
+
50% { background: #4ADE80; }
|
| 1592 |
+
100% { background: #16A34A; }
|
| 1593 |
+
}
|
| 1594 |
+
|
| 1595 |
+
.ccai-human-slot-body {
|
| 1596 |
+
flex: 1;
|
| 1597 |
+
padding: 12px 14px;
|
| 1598 |
+
display: flex;
|
| 1599 |
+
flex-direction: column;
|
| 1600 |
+
gap: 8px;
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
.ccai-human-slot-header {
|
| 1604 |
+
display: flex;
|
| 1605 |
+
align-items: center;
|
| 1606 |
+
gap: 8px;
|
| 1607 |
+
font-size: 13px;
|
| 1608 |
+
}
|
| 1609 |
+
|
| 1610 |
+
.ccai-human-slot-name {
|
| 1611 |
+
font-weight: 700;
|
| 1612 |
+
color: #16A34A;
|
| 1613 |
+
}
|
| 1614 |
+
|
| 1615 |
+
[data-theme="dark"] .ccai-human-slot-name {
|
| 1616 |
+
color: #4ADE80;
|
| 1617 |
+
}
|
| 1618 |
+
|
| 1619 |
+
.ccai-human-slot-pulse {
|
| 1620 |
+
width: 8px;
|
| 1621 |
+
height: 8px;
|
| 1622 |
+
border-radius: 50%;
|
| 1623 |
+
background: #16A34A;
|
| 1624 |
+
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.6);
|
| 1625 |
+
animation: ccai-human-pulse-dot 1.4s ease-in-out infinite;
|
| 1626 |
+
}
|
| 1627 |
+
|
| 1628 |
+
@keyframes ccai-human-pulse-dot {
|
| 1629 |
+
0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.6); }
|
| 1630 |
+
70% { box-shadow: 0 0 0 8px rgba(22, 163, 74, 0); }
|
| 1631 |
+
100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); }
|
| 1632 |
+
}
|
| 1633 |
+
|
| 1634 |
+
.ccai-human-slot-prompt {
|
| 1635 |
+
color: var(--text-secondary);
|
| 1636 |
+
font-style: italic;
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
.ccai-human-slot-context {
|
| 1640 |
+
font-size: 12.5px;
|
| 1641 |
+
color: var(--text-secondary);
|
| 1642 |
+
background: rgba(22, 163, 74, 0.06);
|
| 1643 |
+
border-left: 3px solid rgba(22, 163, 74, 0.6);
|
| 1644 |
+
padding: 6px 10px;
|
| 1645 |
+
border-radius: 4px;
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
+
.ccai-human-slot-textarea {
|
| 1649 |
+
width: 100%;
|
| 1650 |
+
border: 1px solid var(--border-primary);
|
| 1651 |
+
border-radius: 6px;
|
| 1652 |
+
background: var(--bg-primary);
|
| 1653 |
+
color: var(--text-primary);
|
| 1654 |
+
font-family: inherit;
|
| 1655 |
+
font-size: 14px;
|
| 1656 |
+
line-height: 1.45;
|
| 1657 |
+
padding: 10px 12px;
|
| 1658 |
+
resize: vertical;
|
| 1659 |
+
min-height: 80px;
|
| 1660 |
+
box-sizing: border-box;
|
| 1661 |
+
}
|
| 1662 |
+
|
| 1663 |
+
.ccai-human-slot-textarea:focus {
|
| 1664 |
+
outline: none;
|
| 1665 |
+
border-color: #16A34A;
|
| 1666 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1667 |
+
}
|
| 1668 |
+
|
| 1669 |
+
.ccai-human-slot-actions {
|
| 1670 |
+
display: flex;
|
| 1671 |
+
align-items: center;
|
| 1672 |
+
justify-content: space-between;
|
| 1673 |
+
gap: 8px;
|
| 1674 |
+
}
|
| 1675 |
+
|
| 1676 |
+
.ccai-human-slot-hint {
|
| 1677 |
+
font-size: 11.5px;
|
| 1678 |
+
color: var(--text-tertiary);
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
+
.ccai-human-slot-actions-right {
|
| 1682 |
+
display: flex;
|
| 1683 |
+
gap: 8px;
|
| 1684 |
+
}
|
| 1685 |
+
|
| 1686 |
+
/* ── Bubble accent for the human's posted messages ────────────── */
|
| 1687 |
+
|
| 1688 |
+
.ccai-message-row-human .ccai-bubble {
|
| 1689 |
+
border-left: 4px solid #16A34A !important;
|
| 1690 |
+
padding-left: 12px;
|
| 1691 |
+
}
|
| 1692 |
+
|
| 1693 |
+
[data-theme="dark"] .ccai-message-row-human .ccai-bubble {
|
| 1694 |
+
border-left-color: #4ADE80 !important;
|
| 1695 |
+
}
|
| 1696 |
+
|
| 1697 |
+
/* ── Fixed-position lower-screen "waiting for your input" cue ──── */
|
| 1698 |
+
|
| 1699 |
+
.ccai-human-indicator {
|
| 1700 |
+
position: fixed;
|
| 1701 |
+
bottom: 18px;
|
| 1702 |
+
left: 50%;
|
| 1703 |
+
transform: translateX(-50%);
|
| 1704 |
+
display: inline-flex;
|
| 1705 |
+
align-items: center;
|
| 1706 |
+
gap: 8px;
|
| 1707 |
+
padding: 10px 18px;
|
| 1708 |
+
background: #16A34A;
|
| 1709 |
+
color: #ffffff;
|
| 1710 |
+
border: none;
|
| 1711 |
+
border-radius: 999px;
|
| 1712 |
+
font-size: 13px;
|
| 1713 |
+
font-weight: 600;
|
| 1714 |
+
letter-spacing: 0.01em;
|
| 1715 |
+
box-shadow:
|
| 1716 |
+
0 8px 22px rgba(22, 163, 74, 0.35),
|
| 1717 |
+
0 0 0 0 rgba(22, 163, 74, 0.5);
|
| 1718 |
+
cursor: pointer;
|
| 1719 |
+
z-index: 80;
|
| 1720 |
+
animation: ccai-human-indicator-pulse 1.8s ease-in-out infinite;
|
| 1721 |
+
}
|
| 1722 |
+
|
| 1723 |
+
[data-theme="dark"] .ccai-human-indicator {
|
| 1724 |
+
background: #22C55E;
|
| 1725 |
+
}
|
| 1726 |
+
|
| 1727 |
+
@keyframes ccai-human-indicator-pulse {
|
| 1728 |
+
0% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 0 rgba(22, 163, 74, 0.5); }
|
| 1729 |
+
60% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 14px rgba(22, 163, 74, 0); }
|
| 1730 |
+
100% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 0 rgba(22, 163, 74, 0); }
|
| 1731 |
+
}
|
| 1732 |
+
|
| 1733 |
+
.ccai-human-indicator:hover {
|
| 1734 |
+
background: #15803D;
|
| 1735 |
+
}
|
| 1736 |
+
|
| 1737 |
+
[data-theme="dark"] .ccai-human-indicator:hover {
|
| 1738 |
+
background: #16A34A;
|
| 1739 |
+
}
|
| 1740 |
+
|
| 1741 |
+
.ccai-human-indicator-arrow {
|
| 1742 |
+
animation: ccai-human-indicator-arrow-bob 1.4s ease-in-out infinite;
|
| 1743 |
+
}
|
| 1744 |
+
|
| 1745 |
+
@keyframes ccai-human-indicator-arrow-bob {
|
| 1746 |
+
0%, 100% { transform: translateY(0); }
|
| 1747 |
+
50% { transform: translateY(3px); }
|
| 1748 |
+
}
|
| 1749 |
+
|
| 1750 |
+
/* ── CredentialSummaryModal: human row + inline edit ──────────── */
|
| 1751 |
+
|
| 1752 |
+
.ccai-credential-card-human {
|
| 1753 |
+
border-left: 4px solid #16A34A;
|
| 1754 |
+
}
|
| 1755 |
+
|
| 1756 |
+
[data-theme="dark"] .ccai-credential-card-human {
|
| 1757 |
+
border-left-color: #4ADE80;
|
| 1758 |
+
}
|
| 1759 |
+
|
| 1760 |
+
.ccai-credential-human-tag {
|
| 1761 |
+
display: inline-block;
|
| 1762 |
+
margin-left: 8px;
|
| 1763 |
+
padding: 1px 6px;
|
| 1764 |
+
font-size: 10px;
|
| 1765 |
+
font-weight: 600;
|
| 1766 |
+
text-transform: uppercase;
|
| 1767 |
+
letter-spacing: 0.04em;
|
| 1768 |
+
color: #16A34A;
|
| 1769 |
+
background: rgba(22, 163, 74, 0.1);
|
| 1770 |
+
border-radius: 4px;
|
| 1771 |
+
vertical-align: middle;
|
| 1772 |
+
}
|
| 1773 |
+
|
| 1774 |
+
[data-theme="dark"] .ccai-credential-human-tag {
|
| 1775 |
+
color: #4ADE80;
|
| 1776 |
+
background: rgba(74, 222, 128, 0.12);
|
| 1777 |
+
}
|
| 1778 |
+
|
| 1779 |
+
.ccai-credential-edit-btn {
|
| 1780 |
+
display: inline-flex;
|
| 1781 |
+
align-items: center;
|
| 1782 |
+
gap: 4px;
|
| 1783 |
+
margin-left: 8px;
|
| 1784 |
+
}
|
| 1785 |
+
|
| 1786 |
+
.ccai-credential-card-editing {
|
| 1787 |
+
background: rgba(22, 163, 74, 0.04);
|
| 1788 |
+
}
|
| 1789 |
+
|
| 1790 |
+
[data-theme="dark"] .ccai-credential-card-editing {
|
| 1791 |
+
background: rgba(74, 222, 128, 0.06);
|
| 1792 |
+
}
|
| 1793 |
+
|
| 1794 |
+
.ccai-credential-edit-name {
|
| 1795 |
+
padding: 4px 8px;
|
| 1796 |
+
border: 1px solid var(--border-primary);
|
| 1797 |
+
border-radius: 4px;
|
| 1798 |
+
background: var(--card-bg);
|
| 1799 |
+
color: var(--text-primary);
|
| 1800 |
+
font-size: 14px;
|
| 1801 |
+
font-weight: 600;
|
| 1802 |
+
margin-left: 0;
|
| 1803 |
+
}
|
| 1804 |
+
|
| 1805 |
+
.ccai-credential-row-edit {
|
| 1806 |
+
display: flex;
|
| 1807 |
+
flex-direction: column;
|
| 1808 |
+
gap: 4px;
|
| 1809 |
+
}
|
| 1810 |
+
|
| 1811 |
+
.ccai-credential-row-input {
|
| 1812 |
+
width: 100%;
|
| 1813 |
+
padding: 6px 8px;
|
| 1814 |
+
border: 1px solid var(--border-primary);
|
| 1815 |
+
border-radius: 4px;
|
| 1816 |
+
background: var(--card-bg);
|
| 1817 |
+
color: var(--text-primary);
|
| 1818 |
+
font-family: inherit;
|
| 1819 |
+
font-size: 13px;
|
| 1820 |
+
resize: vertical;
|
| 1821 |
+
box-sizing: border-box;
|
| 1822 |
+
}
|
| 1823 |
+
|
| 1824 |
+
.ccai-credential-row-input-num {
|
| 1825 |
+
width: 90px;
|
| 1826 |
+
}
|
| 1827 |
+
|
| 1828 |
+
.ccai-credential-edit-actions {
|
| 1829 |
+
display: flex;
|
| 1830 |
+
justify-content: flex-end;
|
| 1831 |
+
gap: 8px;
|
| 1832 |
+
margin-top: 8px;
|
| 1833 |
+
}
|
| 1834 |
+
|
frontend/src/utils/api.js
CHANGED
|
@@ -126,6 +126,8 @@ function eventHandlerKey(eventType) {
|
|
| 126 |
case 'orchestrator_cap_pause': return 'onOrchestratorCapPause';
|
| 127 |
case 'participant_error': return 'onParticipantError';
|
| 128 |
case 'credentials_updated': return 'onCredentialsUpdated';
|
|
|
|
|
|
|
| 129 |
default: return null;
|
| 130 |
}
|
| 131 |
}
|
|
@@ -244,6 +246,84 @@ export async function fetchCredentials(sessionId) {
|
|
| 244 |
return resp.json();
|
| 245 |
}
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
export async function getAuthStatus() {
|
| 248 |
const resp = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
|
| 249 |
if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
|
|
|
|
| 126 |
case 'orchestrator_cap_pause': return 'onOrchestratorCapPause';
|
| 127 |
case 'participant_error': return 'onParticipantError';
|
| 128 |
case 'credentials_updated': return 'onCredentialsUpdated';
|
| 129 |
+
case 'human_turn_needed': return 'onHumanTurnNeeded';
|
| 130 |
+
case 'human_turn_cleared': return 'onHumanTurnCleared';
|
| 131 |
default: return null;
|
| 132 |
}
|
| 133 |
}
|
|
|
|
| 246 |
return resp.json();
|
| 247 |
}
|
| 248 |
|
| 249 |
+
/**
|
| 250 |
+
* Submit the human participant's response to the orchestrator for the
|
| 251 |
+
* currently pending turn. `skip=true` flips the turn into a "declined
|
| 252 |
+
* to comment" note rather than a message.
|
| 253 |
+
*/
|
| 254 |
+
export async function submitHumanResponse(sessionId, { text, skip = false } = {}) {
|
| 255 |
+
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/human-response`, {
|
| 256 |
+
method: 'POST',
|
| 257 |
+
headers: { 'Content-Type': 'application/json' },
|
| 258 |
+
body: JSON.stringify({ text: text || '', skip: !!skip }),
|
| 259 |
+
});
|
| 260 |
+
if (!resp.ok) {
|
| 261 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 262 |
+
throw new Error(err.detail || 'Submit failed');
|
| 263 |
+
}
|
| 264 |
+
return resp.json();
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/**
|
| 268 |
+
* Patch the in-the-loop human's credential summary. Used by the
|
| 269 |
+
* CredentialSummaryModal's edit affordance on the human's row. The
|
| 270 |
+
* backend rejects fields it doesn't know about; we send only the
|
| 271 |
+
* fields the user actually changed (sparse patch).
|
| 272 |
+
*/
|
| 273 |
+
export async function patchHumanCredential(sessionId, patch) {
|
| 274 |
+
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/credentials/human`, {
|
| 275 |
+
method: 'PATCH',
|
| 276 |
+
headers: { 'Content-Type': 'application/json' },
|
| 277 |
+
body: JSON.stringify(patch),
|
| 278 |
+
});
|
| 279 |
+
if (!resp.ok) {
|
| 280 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 281 |
+
throw new Error(err.detail || 'Edit failed');
|
| 282 |
+
}
|
| 283 |
+
return resp.json();
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/**
|
| 287 |
+
* Start the AI-assisted credential intake Q&A flow. Returns either a
|
| 288 |
+
* first question or (rarely) a final summary if the LLM bails. The
|
| 289 |
+
* draft_id is needed for subsequent /answer calls.
|
| 290 |
+
*/
|
| 291 |
+
export async function startCredentialDraft({
|
| 292 |
+
name, question, max_questions = 6, orchestrator_model_id = null,
|
| 293 |
+
}) {
|
| 294 |
+
const resp = await fetch(`${API_BASE}/api/chat/credentials/draft`, {
|
| 295 |
+
method: 'POST',
|
| 296 |
+
headers: { 'Content-Type': 'application/json' },
|
| 297 |
+
body: JSON.stringify({ name, question, max_questions, orchestrator_model_id }),
|
| 298 |
+
});
|
| 299 |
+
if (!resp.ok) {
|
| 300 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 301 |
+
throw new Error(err.detail || 'Credential draft start failed');
|
| 302 |
+
}
|
| 303 |
+
return resp.json();
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
export async function answerCredentialDraft(draftId, answer) {
|
| 307 |
+
const resp = await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}/answer`, {
|
| 308 |
+
method: 'POST',
|
| 309 |
+
headers: { 'Content-Type': 'application/json' },
|
| 310 |
+
body: JSON.stringify({ answer: answer || '' }),
|
| 311 |
+
});
|
| 312 |
+
if (!resp.ok) {
|
| 313 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 314 |
+
throw new Error(err.detail || 'Credential draft answer failed');
|
| 315 |
+
}
|
| 316 |
+
return resp.json();
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
export async function cancelCredentialDraft(draftId) {
|
| 320 |
+
try {
|
| 321 |
+
await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}`, {
|
| 322 |
+
method: 'DELETE',
|
| 323 |
+
});
|
| 324 |
+
} catch (_) { /* fire-and-forget cleanup; ignore */ }
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
export async function getAuthStatus() {
|
| 328 |
const resp = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
|
| 329 |
if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
|
frontend/src/utils/storage.js
CHANGED
|
@@ -28,6 +28,16 @@ const DEFAULTS = {
|
|
| 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() {
|
|
@@ -104,3 +114,7 @@ export function setConversationLimits(limitsMap) {
|
|
| 104 |
export function setAutoSelectMode(on) {
|
| 105 |
return patchState({ auto_select_mode: !!on });
|
| 106 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
// In-the-loop human participant. null when no human is configured.
|
| 32 |
+
// Shape when set:
|
| 33 |
+
// { participant_id, name, credential_summary: {
|
| 34 |
+
// name, expertise, personality,
|
| 35 |
+
// credibility_for_question, bias_to_watch
|
| 36 |
+
// } }
|
| 37 |
+
// Persisted across page reloads so the user doesn't have to re-author
|
| 38 |
+
// their summary every session. Cleared from storage when the user
|
| 39 |
+
// removes the human via the sidebar.
|
| 40 |
+
human_participant: null,
|
| 41 |
};
|
| 42 |
|
| 43 |
function readAll() {
|
|
|
|
| 114 |
export function setAutoSelectMode(on) {
|
| 115 |
return patchState({ auto_select_mode: !!on });
|
| 116 |
}
|
| 117 |
+
|
| 118 |
+
export function setHumanParticipant(humanOrNull) {
|
| 119 |
+
return patchState({ human_participant: humanOrNull || null });
|
| 120 |
+
}
|