NeonClary Cursor commited on
Commit
11bf9b7
·
1 Parent(s): 0239f6d

feat: human participant turns + credential intake redesign

Browse files

Human 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 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
- creds: list[dict[str, Any]] = []
83
- if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list):
84
- creds = parsed["credentials"]
 
 
 
 
 
85
 
86
- creds = _normalize_creds(creds, participants)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": existing}, indent=2),
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
- return _normalize_creds(parsed["credentials"], participants)
 
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, and user-created Expert Personas. `enabled` reflects the
235
- sidebar slider. Disabled participants are kept on the session so
236
- the user can re-enable mid-conversation, but they don't take turns.
 
 
 
 
 
 
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
- return selectedIds
163
  .map(id => allCatalogParticipants[id])
164
  .filter(Boolean);
165
- }, [selectedIds, allCatalogParticipants]);
 
 
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: modelAssignments[p.participant_id] || null,
 
 
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: maxParticipants,
491
  candidates: candidatesPayload,
492
  orchestrator_model_id: orchestratorModel,
493
  });
494
  const chosenIds = result.selected || [];
495
- resolvedParticipants = chosenIds
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
- <CredentialCard key={c.participant_id} cred={c} />
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 className="ccai-credential-card">
 
 
 
 
 
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}>&times;</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 tone = colorForIdx(idx);
 
 
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' + (enabled ? '' : ' ccai-participant-card-off')
 
 
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">{participant.name}</div>
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div className="ccai-participant-field">
120
- <div className="ccai-participant-field-label">LLM</div>
121
- <div className="ccai-participant-field-value">
122
- {modelOverride || participant.default_model_id || participant.model_display || ''}
 
 
 
 
 
123
  </div>
124
- </div>
125
- <div className="ccai-participant-field">
126
- <div className="ccai-participant-field-label">Persona prompt</div>
127
- <pre className="ccai-participant-prompt">
128
- {participant.role_prompt || '(no prompt set)'}
129
- </pre>
130
- </div>
 
 
 
 
 
 
 
 
 
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:&nbsp;…" 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
+ }