Jordan Miller Cursor commited on
Commit
4944128
·
1 Parent(s): 40dee71

Add demo trio personas and polish empty-state panel UX.

Browse files

Introduce Elena, Marcus, and Dr. Amira as default catalog participants with a build-vs-buy sample preview, dynamic empty-state guidance, readable bubble text, and sidebar prompt display fixes; reassign Marcus to Mistral after Fireworks DeepSeek v3.2 404s locally.

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

backend/app/api/personas.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  `GET /api/personas` returns three sections - Neon HANA personas (with
4
  vanilla/RAG personas filtered out and display names reformatted via an
5
- LLM), the four bundled extra personas, and the user-supplied expert
6
  personas (which are local-only on the frontend but echoed here for
7
  completeness so the API can act as the source of truth for participant
8
  choices when needed).
 
2
 
3
  `GET /api/personas` returns three sections - Neon HANA personas (with
4
  vanilla/RAG personas filtered out and display names reformatted via an
5
+ LLM), the bundled extra personas, and the user-supplied expert
6
  personas (which are local-only on the frontend but echoed here for
7
  completeness so the API can act as the source of truth for participant
8
  choices when needed).
backend/app/data/demo_questions.json CHANGED
@@ -2,22 +2,10 @@
2
  "version": 1,
3
  "questions": [
4
  {
5
- "id": "best_pet",
6
- "category": "lifestyle",
7
- "title": "Which is the best pet, cats or dogs?",
8
- "text": "Which is the best pet, cats or dogs?"
9
- },
10
- {
11
- "id": "undergrad_majors",
12
- "category": "education",
13
- "title": "Best undergraduate majors for career prospects",
14
- "text": "What are the three best majors for my undergraduate degree? I live in Washington State, USA, and I am starting college in the fall. My biggest priority is to make sure I have good job prospects when I graduate in 2031. I love using AI and exploring new technology, math is one of my strongest subjects, I don't want to travel for my job, and I don't want to have to publish papers as part of my job. I like to talk to people, and to travel. I know a lot about finance because my parents are both accountants. I also love animals."
15
- },
16
- {
17
- "id": "small_business_ai",
18
- "category": "small business",
19
- "title": "Should a small bakery introduce AI?",
20
- "text": "Should I introduce AI tools into my small bakery, and if so, where should I start? I run a single-storefront bakery in a tourist town in Vermont with three full-time employees and four part-timers. We do counter sales, weekend wholesale to two coffee shops, and a small custom-cake business. I'm 54, comfortable with tech but not a developer. My priorities, in order: keep the staff feeling valued, hold the food quality steady, save myself maybe 5-10 hours a week of paperwork, and slowly grow custom-cake revenue. I have about $3,000 a year I could redirect into software or services without straining the books."
21
  },
22
  {
23
  "id": "rural_water",
@@ -25,6 +13,12 @@
25
  "title": "Best fix for a small town's failing water system",
26
  "text": "Our rural town in central Pennsylvania has roughly 1,800 residents and a 1960s-era municipal water system that has had three boil-water advisories in the last two years. We have a small budget and an aging volunteer water board. The state has offered a 50% matching grant but only if we commit to a project plan within nine months. The three options on the table are: (a) replace the existing main lines incrementally over twelve years, (b) consolidate with a neighboring town's larger system, losing local control but gaining redundancy, or (c) install a smaller modern treatment plant of our own with recurring maintenance contracts. Residents are split, and the median household income is around $52,000."
27
  },
 
 
 
 
 
 
28
  {
29
  "id": "acquisition_decision",
30
  "category": "business / M&A",
@@ -37,6 +31,12 @@
37
  "title": "AI policy for a public middle school",
38
  "text": "I'm a principal at a public middle school in Texas, grades 6-8, 480 students. Many of my teachers are quietly using ChatGPT and similar tools to plan lessons and draft feedback. About a third of students are using AI for homework, sometimes well, sometimes to skip thinking entirely. The school board wants me to publish a formal AI policy by the end of the semester. I have to balance student learning, equity (not all students have the same access at home), staff workload, parental concerns about screen time and privacy, and our district's tight budget. Should I lean toward restricting AI, embracing it, or some structured middle path - and what would the most important rules be?"
39
  },
 
 
 
 
 
 
40
  {
41
  "id": "career_pivot_30s",
42
  "category": "career",
@@ -49,6 +49,12 @@
49
  "title": "Buying property knowing the climate is shifting",
50
  "text": "My partner and I are looking to buy a 5-15 acre property within a four-hour drive of Denver. We want a small homestead - some chickens, a vegetable garden, room for two large dogs - and a place we can live in long term. Our budget is around $550,000 including a modest house. We're worried about climate exposure: wildfire smoke, water-rights pressure on small wells, longer droughts, and how much insurance is going to cost ten years from now. We're also worried about being too far from a hospital. Neither of us has farming experience but we're hands-on. How do we think about which properties to look at, what to actually optimize for, and what we should be willing to compromise on?"
51
  },
 
 
 
 
 
 
52
  {
53
  "id": "open_source_strategy",
54
  "category": "tech / business strategy",
 
2
  "version": 1,
3
  "questions": [
4
  {
5
+ "id": "build_vs_buy_ai",
6
+ "category": "tech / business strategy",
7
+ "title": "Should a company build its own AI system or rely on a third-party provider?",
8
+ "text": "Should a company build its own AI system or rely on a third-party provider?"
 
 
 
 
 
 
 
 
 
 
 
 
9
  },
10
  {
11
  "id": "rural_water",
 
13
  "title": "Best fix for a small town's failing water system",
14
  "text": "Our rural town in central Pennsylvania has roughly 1,800 residents and a 1960s-era municipal water system that has had three boil-water advisories in the last two years. We have a small budget and an aging volunteer water board. The state has offered a 50% matching grant but only if we commit to a project plan within nine months. The three options on the table are: (a) replace the existing main lines incrementally over twelve years, (b) consolidate with a neighboring town's larger system, losing local control but gaining redundancy, or (c) install a smaller modern treatment plant of our own with recurring maintenance contracts. Residents are split, and the median household income is around $52,000."
15
  },
16
+ {
17
+ "id": "undergrad_majors",
18
+ "category": "education",
19
+ "title": "Best undergraduate majors for career prospects",
20
+ "text": "What are the three best majors for my undergraduate degree? I live in Washington State, USA, and I am starting college in the fall. My biggest priority is to make sure I have good job prospects when I graduate in 2031. I love using AI and exploring new technology, math is one of my strongest subjects, I don't want to travel for my job, and I don't want to have to publish papers as part of my job. I like to talk to people, and to travel. I know a lot about finance because my parents are both accountants. I also love animals."
21
+ },
22
  {
23
  "id": "acquisition_decision",
24
  "category": "business / M&A",
 
31
  "title": "AI policy for a public middle school",
32
  "text": "I'm a principal at a public middle school in Texas, grades 6-8, 480 students. Many of my teachers are quietly using ChatGPT and similar tools to plan lessons and draft feedback. About a third of students are using AI for homework, sometimes well, sometimes to skip thinking entirely. The school board wants me to publish a formal AI policy by the end of the semester. I have to balance student learning, equity (not all students have the same access at home), staff workload, parental concerns about screen time and privacy, and our district's tight budget. Should I lean toward restricting AI, embracing it, or some structured middle path - and what would the most important rules be?"
33
  },
34
+ {
35
+ "id": "small_business_ai",
36
+ "category": "small business",
37
+ "title": "Should a small bakery introduce AI?",
38
+ "text": "Should I introduce AI tools into my small bakery, and if so, where should I start? I run a single-storefront bakery in a tourist town in Vermont with three full-time employees and four part-timers. We do counter sales, weekend wholesale to two coffee shops, and a small custom-cake business. I'm 54, comfortable with tech but not a developer. My priorities, in order: keep the staff feeling valued, hold the food quality steady, save myself maybe 5-10 hours a week of paperwork, and slowly grow custom-cake revenue. I have about $3,000 a year I could redirect into software or services without straining the books."
39
+ },
40
  {
41
  "id": "career_pivot_30s",
42
  "category": "career",
 
49
  "title": "Buying property knowing the climate is shifting",
50
  "text": "My partner and I are looking to buy a 5-15 acre property within a four-hour drive of Denver. We want a small homestead - some chickens, a vegetable garden, room for two large dogs - and a place we can live in long term. Our budget is around $550,000 including a modest house. We're worried about climate exposure: wildfire smoke, water-rights pressure on small wells, longer droughts, and how much insurance is going to cost ten years from now. We're also worried about being too far from a hospital. Neither of us has farming experience but we're hands-on. How do we think about which properties to look at, what to actually optimize for, and what we should be willing to compromise on?"
51
  },
52
+ {
53
+ "id": "best_pet",
54
+ "category": "lifestyle",
55
+ "title": "Which is the best pet, cats or dogs?",
56
+ "text": "Which is the best pet, cats or dogs?"
57
+ },
58
  {
59
  "id": "open_source_strategy",
60
  "category": "tech / business strategy",
backend/app/services/extra_personas.py CHANGED
@@ -1,4 +1,4 @@
1
- """Four bundled "extra" personas powered by non-Neon LLMs.
2
 
3
  Each pairs a discussion lens with a complementary area of expertise so
4
  they generalize to any question. The user can replace any of them by
@@ -105,6 +105,67 @@ EXTRA_PERSONAS: list[ExtraPersonaSpec] = [
105
  "it."
106
  ),
107
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  ]
109
 
110
 
 
1
+ """Bundled "extra" personas powered by provider and Neon LLMs.
2
 
3
  Each pairs a discussion lens with a complementary area of expertise so
4
  they generalize to any question. The user can replace any of them by
 
105
  "it."
106
  ),
107
  ),
108
+ ExtraPersonaSpec(
109
+ participant_id="extra_elena_financial_strategist",
110
+ name="Elena — Financial Strategist",
111
+ default_model_id="gpt-4.1",
112
+ role_prompt=(
113
+ "You are Elena, a financial strategist. You evaluate every question "
114
+ "through cost, return on investment, total cost of ownership, budget "
115
+ "tradeoffs, and financial risk. You think in three-to-five-year "
116
+ "horizons and default to back-of-envelope math rather than "
117
+ "hand-waving. Your tone is pragmatic and numbers-grounded: you "
118
+ "compare options on unit economics, break-even timing, and what has "
119
+ "to be true for the numbers to work. You are general-purpose enough "
120
+ "to join any panel discussion, but you always bring a finance lens — "
121
+ "opportunity cost, capital allocation, and whether proposed benefits "
122
+ "justify the spend. You speak in short, clear paragraphs with "
123
+ "concrete figures when you can estimate them, and you flag when "
124
+ "someone's case depends on assumptions they have not priced in. You "
125
+ "update your view when shown credible financial evidence, but you "
126
+ "ask what the downside looks like on the balance sheet before "
127
+ "agreeing."
128
+ ),
129
+ ),
130
+ ExtraPersonaSpec(
131
+ participant_id="extra_marcus_technology_strategist",
132
+ name="Marcus — Technology Strategist",
133
+ default_model_id="mistral-small-2603",
134
+ role_prompt=(
135
+ "You are Marcus, a technology strategist. You evaluate technical "
136
+ "feasibility, architecture, build-versus-buy tradeoffs, integration "
137
+ "complexity, and long-term maintainability. Your tone is direct and "
138
+ "practical: you focus on what is actually buildable, operable, and "
139
+ "worth owning. On any topic you ask what the system has to do, what "
140
+ "already exists to reuse, and where the hard integration or scaling "
141
+ "risks live. You favor options that ship reliably over clever ones "
142
+ "that require heroic engineering. You speak in concise, structured "
143
+ "points, call out hidden dependencies and operational burden, and "
144
+ "distinguish 'possible in a demo' from 'sustainable in production.' "
145
+ "You advocate building when the product is the technology itself, "
146
+ "and buying when the problem is well-served commodity capability. "
147
+ "You change your mind when someone shows a simpler architecture or "
148
+ "a realistic delivery path you had not considered."
149
+ ),
150
+ ),
151
+ ExtraPersonaSpec(
152
+ participant_id="extra_amira_security_advisor",
153
+ name="Dr. Amira — Security & Privacy Advisor",
154
+ default_model_id="neon:BrainForge/Security@2026.05.13:CybersecurityExpert",
155
+ role_prompt=(
156
+ "You are Dr. Amira, a security and privacy advisor. You evaluate every question "
157
+ "through the lens of data security, privacy, regulatory and compliance risk, and "
158
+ "where data actually lives — including retention, subprocessors, data residency, "
159
+ "and whether sensitive information ever leaves an organization's boundary. Your "
160
+ "tone is careful, precise, and risk-aware. You weigh the real-world tradeoffs "
161
+ "between security and practicality rather than defaulting to 'lock everything "
162
+ "down,' and you flag when a decision depends on the specific regulatory "
163
+ "environment (healthcare, finance, government, etc.). You give clear, substantive "
164
+ "replies and engage directly with other participants' points, especially when a "
165
+ "technical or cost argument has security or privacy implications they haven't "
166
+ "accounted for."
167
+ ),
168
+ ),
169
  ]
170
 
171
 
frontend/src/App.js CHANGED
@@ -48,12 +48,16 @@ function appendInlineChatNote(setMessages, text, extra = {}) {
48
  export default function App() {
49
  // Persistent state
50
  const persisted = useMemo(() => storage.loadState(), []);
 
 
 
 
51
  const [theme, setTheme] = useState(() => persisted.theme
52
  || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
53
  );
54
  const [expertPersonas, setExpertPersonas] = useState(persisted.expert_personas || []);
55
- const [selectedIds, setSelectedIds] = useState(persisted.participants_selected || []);
56
- const [enabledMap, setEnabledMap] = useState(persisted.participants_enabled || {});
57
  const [modelAssignments, setModelAssignments] = useState(persisted.model_assignments || {});
58
  const [orchestratorModel, setOrchestratorModelState] = useState(persisted.orchestrator_model_id);
59
  const [summarizerModel, setSummarizerModelState] = useState(persisted.summarizer_model_id);
@@ -1074,14 +1078,20 @@ export default function App() {
1074
  // available in the catalog.
1075
  const autoSelectReady = autoSelectMode
1076
  && Object.keys(allCatalogParticipants).length >= 2;
1077
- const startDisabled = isRunning
1078
- || (!autoSelectMode && enabledSelectedCount < 2)
1079
- || (autoSelectMode && !autoSelectReady);
 
1080
  const startDisabledReason = autoSelectMode
1081
  ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
1082
  : enabledSelectedCount < 2
1083
  ? 'Add at least 2 active participants to start.'
1084
  : '';
 
 
 
 
 
1085
 
1086
  return (
1087
  <div className="app">
@@ -1154,12 +1164,14 @@ export default function App() {
1154
  disabled={startDisabled}
1155
  isRunning={isRunning}
1156
  disabledReason={startDisabledReason}
 
1157
  activeQuestion={activeQuestion}
1158
  />
1159
  <ChatArea
1160
  messages={messages}
1161
  systemMessages={systemMessages}
1162
  isRunning={isRunning}
 
1163
  statusText={statusText}
1164
  pause={pause}
1165
  onContinuePause={handleContinuePause}
 
48
  export default function App() {
49
  // Persistent state
50
  const persisted = useMemo(() => storage.loadState(), []);
51
+ const initialParticipants = useMemo(
52
+ () => storage.resolveInitialParticipants(persisted),
53
+ [persisted],
54
+ );
55
  const [theme, setTheme] = useState(() => persisted.theme
56
  || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
57
  );
58
  const [expertPersonas, setExpertPersonas] = useState(persisted.expert_personas || []);
59
+ const [selectedIds, setSelectedIds] = useState(initialParticipants.selectedIds);
60
+ const [enabledMap, setEnabledMap] = useState(initialParticipants.enabledMap);
61
  const [modelAssignments, setModelAssignments] = useState(persisted.model_assignments || {});
62
  const [orchestratorModel, setOrchestratorModelState] = useState(persisted.orchestrator_model_id);
63
  const [summarizerModel, setSummarizerModelState] = useState(persisted.summarizer_model_id);
 
1078
  // available in the catalog.
1079
  const autoSelectReady = autoSelectMode
1080
  && Object.keys(allCatalogParticipants).length >= 2;
1081
+ const hasEnoughParticipantsToStart = autoSelectMode
1082
+ ? autoSelectReady
1083
+ : enabledSelectedCount >= 2;
1084
+ const startDisabled = isRunning || !hasEnoughParticipantsToStart;
1085
  const startDisabledReason = autoSelectMode
1086
  ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
1087
  : enabledSelectedCount < 2
1088
  ? 'Add at least 2 active participants to start.'
1089
  : '';
1090
+ const startDisabledTooltip = autoSelectMode
1091
+ ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
1092
+ : enabledSelectedCount < 2
1093
+ ? 'Select at least 2 participants.'
1094
+ : '';
1095
 
1096
  return (
1097
  <div className="app">
 
1164
  disabled={startDisabled}
1165
  isRunning={isRunning}
1166
  disabledReason={startDisabledReason}
1167
+ disabledTooltip={startDisabledTooltip}
1168
  activeQuestion={activeQuestion}
1169
  />
1170
  <ChatArea
1171
  messages={messages}
1172
  systemMessages={systemMessages}
1173
  isRunning={isRunning}
1174
+ hasEnoughParticipantsToStart={hasEnoughParticipantsToStart}
1175
  statusText={statusText}
1176
  pause={pause}
1177
  onContinuePause={handleContinuePause}
frontend/src/components/ChatArea.js CHANGED
@@ -5,6 +5,70 @@ import OrchestratorMessage from './OrchestratorMessage';
5
  import FailsafePauseBanner from './FailsafePauseBanner';
6
  import HumanInputSlot from './HumanInputSlot';
7
  import HumanTurnIndicator from './HumanTurnIndicator';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  /**
10
  * Renders the conversation: a mix of participant bubbles, orchestrator
@@ -21,6 +85,7 @@ export default function ChatArea({
21
  messages,
22
  systemMessages,
23
  isRunning,
 
24
  statusText,
25
  pause,
26
  onContinuePause,
@@ -54,6 +119,37 @@ export default function ChatArea({
54
  return m;
55
  }, [participants]);
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  const hasContent = (messages?.length || 0) + (systemMessages?.length || 0) > 0;
58
  const chatEnded = (systemMessages || []).some(s => s.text === 'End of Chat');
59
 
@@ -70,7 +166,46 @@ export default function ChatArea({
70
  <div className="chat-area">
71
  {!hasContent && !isRunning && (
72
  <div className="chat-empty">
73
- Add at least 2 participants from the header dropdown, then start a conversation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </div>
75
  )}
76
  {(messages || []).map((msg, i) => {
@@ -123,51 +258,63 @@ export default function ChatArea({
123
  </div>
124
  )}
125
  {chatEnded && (
126
- <div className="chat-end-downloads" role="group" aria-label="Conversation downloads">
127
- <button
128
- type="button"
129
- className="btn-sm btn-outline chat-end-download-btn"
130
- onClick={onShowTableView}
131
- title="Open the conversation summary table"
132
- >
133
- <Table2 size={14} />
134
- Summary table…
135
- </button>
136
- <button
137
- type="button"
138
- className="btn-sm btn-outline chat-end-download-btn"
139
- onClick={onDownloadChatTxt}
140
- >
141
- <FileText size={14} />
142
- Chat as .txt
143
- </button>
144
- <button
145
- type="button"
146
- className="btn-sm btn-outline chat-end-download-btn"
147
- onClick={onDownloadChatMd}
148
- >
149
- <FileCode size={14} />
150
- Chat as .md
151
- </button>
152
- <button
153
- type="button"
154
- className="btn-sm btn-outline chat-end-download-btn"
155
- onClick={onDownloadCsvTable}
156
- title="Download the summary table as CSV"
157
- >
158
- <FileSpreadsheet size={14} />
159
- Summary table as .csv
160
- </button>
161
- <button
162
- type="button"
163
- className="btn-sm btn-outline chat-end-download-btn"
164
- onClick={onDownloadApiLog}
165
- disabled={!hasApiLog}
166
- title="Download the full backend API call history for this session"
167
- >
168
- <History size={14} />
169
- Full API history
170
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
171
  </div>
172
  )}
173
  <FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
 
5
  import FailsafePauseBanner from './FailsafePauseBanner';
6
  import HumanInputSlot from './HumanInputSlot';
7
  import HumanTurnIndicator from './HumanTurnIndicator';
8
+ import { DEFAULT_DEMO_PERSONAS, DEFAULT_PARTICIPANT_IDS } from '../utils/storage';
9
+
10
+ /** Update when the Neon contact destination changes on the redesigned site. */
11
+ const NEON_CONTACT_URL = 'https://www.neon.ai/contact';
12
+
13
+ const [ELENA_ID, MARCUS_ID, AMIRA_ID] = DEFAULT_PARTICIPANT_IDS;
14
+
15
+ /** Dialogue-only turns for the empty-state sample; speaker ids come from the demo trio. */
16
+ const SAMPLE_PREVIEW_TURNS = [
17
+ {
18
+ kind: 'orchestrator',
19
+ text:
20
+ 'Question: Should a company build its own AI system or rely on a third-party provider? '
21
+ + 'Each participant: share your initial view.',
22
+ },
23
+ {
24
+ kind: 'participant',
25
+ speakerId: ELENA_ID,
26
+ text:
27
+ 'Third-party APIs keep upfront cost low, but per-seat and per-token fees compound fast at scale — '
28
+ + 'above a few million in annual AI spend, owning the stack often wins on total cost over three to five years.',
29
+ },
30
+ {
31
+ kind: 'participant',
32
+ speakerId: MARCUS_ID,
33
+ text:
34
+ 'Build gives you control over models, prompts, and integrations; buy gets you to production in weeks. '
35
+ + "I'd only build if the product is the AI itself, not if AI is supporting something else.",
36
+ },
37
+ {
38
+ kind: 'participant',
39
+ speakerId: AMIRA_ID,
40
+ addressedTo: MARCUS_ID,
41
+ replyingTo: [MARCUS_ID],
42
+ text:
43
+ 'Where data lives matters more than who hosts the model. A vendor with strong certifications can beat a sloppy '
44
+ + 'in-house deployment — but regulated workloads need clarity on retention, subprocessors, and whether prompts leave your boundary.',
45
+ },
46
+ {
47
+ kind: 'orchestrator',
48
+ text:
49
+ 'No single answer fits every company — the panel leans toward buy for speed and lower early risk, build when AI is '
50
+ + 'core IP or scale makes vendor fees dominate, with privacy and compliance as the tiebreaker.',
51
+ },
52
+ ];
53
+
54
+ function buildSamplePreviewMessages(nameById) {
55
+ return SAMPLE_PREVIEW_TURNS.map((turn) => {
56
+ if (turn.kind === 'orchestrator') {
57
+ return { kind: 'orchestrator', text: turn.text };
58
+ }
59
+ const speaker_id = turn.speakerId;
60
+ return {
61
+ kind: 'participant',
62
+ role: 'participant',
63
+ speaker_id,
64
+ speaker_name: nameById[speaker_id] || '',
65
+ text: turn.text,
66
+ ...(turn.addressedTo
67
+ ? { addressed_to: turn.addressedTo, replying_to: turn.replyingTo }
68
+ : {}),
69
+ };
70
+ });
71
+ }
72
 
73
  /**
74
  * Renders the conversation: a mix of participant bubbles, orchestrator
 
85
  messages,
86
  systemMessages,
87
  isRunning,
88
+ hasEnoughParticipantsToStart,
89
  statusText,
90
  pause,
91
  onContinuePause,
 
119
  return m;
120
  }, [participants]);
121
 
122
+ const defaultDemoNameById = useMemo(() => {
123
+ const m = {};
124
+ DEFAULT_DEMO_PERSONAS.forEach((p) => {
125
+ m[p.participant_id] = p.name;
126
+ });
127
+ return m;
128
+ }, []);
129
+
130
+ /** Names for sample bubbles: live roster when present, else catalog mirror in storage. */
131
+ const previewNameById = useMemo(() => {
132
+ const m = { ...defaultDemoNameById };
133
+ DEFAULT_PARTICIPANT_IDS.forEach((id) => {
134
+ if (participantNameById[id]) m[id] = participantNameById[id];
135
+ });
136
+ return m;
137
+ }, [defaultDemoNameById, participantNameById]);
138
+
139
+ /** Palette index: match sidebar roster order, fall back to default trio order. */
140
+ const previewSpeakerIdxFor = useMemo(() => {
141
+ const map = {};
142
+ DEFAULT_PARTICIPANT_IDS.forEach((id, defaultIdx) => {
143
+ map[id] = speakerIdxFor[id] ?? defaultIdx;
144
+ });
145
+ return map;
146
+ }, [speakerIdxFor]);
147
+
148
+ const samplePreviewMessages = useMemo(
149
+ () => buildSamplePreviewMessages(previewNameById),
150
+ [previewNameById],
151
+ );
152
+
153
  const hasContent = (messages?.length || 0) + (systemMessages?.length || 0) > 0;
154
  const chatEnded = (systemMessages || []).some(s => s.text === 'End of Chat');
155
 
 
166
  <div className="chat-area">
167
  {!hasContent && !isRunning && (
168
  <div className="chat-empty">
169
+ <div className="chat-empty-copy">
170
+ <p className="chat-empty-lead">
171
+ Watch a panel of AI experts debate a question, challenge each other, and reason toward a more considered answer.
172
+ </p>
173
+ <p className="chat-empty-instruction">
174
+ {hasEnoughParticipantsToStart
175
+ ? 'Three expert personas are ready to go — press Start Chat, or add, remove, or edit them to fit your question.'
176
+ : 'Add at least 2 participants from the header dropdown, then start a conversation.'}
177
+ </p>
178
+ </div>
179
+ <div className="chat-empty-sample" aria-hidden="true">
180
+ <p className="chat-empty-sample-label">Sample preview — not a live result</p>
181
+ <div className="chat-empty-sample-panel">
182
+ {samplePreviewMessages.map((msg, i) => {
183
+ if (msg.kind === 'orchestrator') {
184
+ return (
185
+ <OrchestratorMessage
186
+ key={`preview-orch-${i}`}
187
+ message={{ text: msg.text }}
188
+ messageIdx={i}
189
+ />
190
+ );
191
+ }
192
+ const prev = i > 0 ? samplePreviewMessages[i - 1] : null;
193
+ const prevParticipant =
194
+ prev?.kind === 'participant' ? prev : null;
195
+ return (
196
+ <MessageBubble
197
+ key={`preview-${msg.speaker_id}-${i}`}
198
+ message={msg}
199
+ idx={previewSpeakerIdxFor[msg.speaker_id] ?? 0}
200
+ messageIdx={i}
201
+ prevMessage={prevParticipant}
202
+ participantNameById={previewNameById}
203
+ showResponseTime={false}
204
+ />
205
+ );
206
+ })}
207
+ </div>
208
+ </div>
209
  </div>
210
  )}
211
  {(messages || []).map((msg, i) => {
 
258
  </div>
259
  )}
260
  {chatEnded && (
261
+ <div className="chat-end-bar">
262
+ <div className="chat-end-downloads" role="group" aria-label="Conversation downloads">
263
+ <button
264
+ type="button"
265
+ className="btn-sm btn-outline chat-end-download-btn"
266
+ onClick={onShowTableView}
267
+ title="Open the conversation summary table"
268
+ >
269
+ <Table2 size={14} />
270
+ Summary table…
271
+ </button>
272
+ <button
273
+ type="button"
274
+ className="btn-sm btn-outline chat-end-download-btn"
275
+ onClick={onDownloadChatTxt}
276
+ >
277
+ <FileText size={14} />
278
+ Chat as .txt
279
+ </button>
280
+ <button
281
+ type="button"
282
+ className="btn-sm btn-outline chat-end-download-btn"
283
+ onClick={onDownloadChatMd}
284
+ >
285
+ <FileCode size={14} />
286
+ Chat as .md
287
+ </button>
288
+ <button
289
+ type="button"
290
+ className="btn-sm btn-outline chat-end-download-btn"
291
+ onClick={onDownloadCsvTable}
292
+ title="Download the summary table as CSV"
293
+ >
294
+ <FileSpreadsheet size={14} />
295
+ Summary table as .csv
296
+ </button>
297
+ <button
298
+ type="button"
299
+ className="btn-sm btn-outline chat-end-download-btn"
300
+ onClick={onDownloadApiLog}
301
+ disabled={!hasApiLog}
302
+ title="Download the full backend API call history for this session"
303
+ >
304
+ <History size={14} />
305
+ Full API history
306
+ </button>
307
+ </div>
308
+ <p className="chat-end-cta">
309
+ Want a panel like this running on your own infrastructure?{' '}
310
+ <a
311
+ href={NEON_CONTACT_URL}
312
+ target="_blank"
313
+ rel="noopener noreferrer"
314
+ >
315
+ Talk to Neon
316
+ </a>
317
+ </p>
318
  </div>
319
  )}
320
  <FailsafePauseBanner pause={pause} onContinue={onContinuePause} />
frontend/src/components/ChatControls.js CHANGED
@@ -20,6 +20,7 @@ const ChatControls = forwardRef(function ChatControls({
20
  disabled,
21
  isRunning,
22
  disabledReason,
 
23
  activeQuestion,
24
  }, ref) {
25
  const demoIndexRef = useRef(0);
@@ -68,7 +69,7 @@ const ChatControls = forwardRef(function ChatControls({
68
  };
69
 
70
  const handleKeyDown = (e) => {
71
- if (disabled || mode !== 'demo') return;
72
  if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
73
  setMode('custom');
74
  setUserText(e.key);
@@ -90,6 +91,7 @@ const ChatControls = forwardRef(function ChatControls({
90
  };
91
 
92
  const canStart = !disabled && !!resolveQuestion();
 
93
 
94
  return (
95
  <div className="chat-controls">
@@ -110,31 +112,51 @@ const ChatControls = forwardRef(function ChatControls({
110
  </>
111
  ) : (
112
  <>
113
- <input
114
- type="text"
115
- value={inputValue}
116
- placeholder={
117
- disabled
118
- ? (disabledReason || 'Add participants to start a conversation')
119
- : (demoQuestions.length === 0 ? 'Loading demo questions…' : '')
120
- }
121
- disabled={disabled}
122
- onChange={handleChange}
123
- onKeyDown={(e) => {
124
- handleKeyDown(e);
125
- if (e.key === 'Enter' && canStart) {
126
- handleStart();
127
  }
128
- }}
129
- />
130
- <button
131
- className="btn-primary"
132
- disabled={!canStart}
133
- onClick={handleStart}
134
- >
135
- <Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
136
- Start Chat
137
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  </>
139
  )}
140
  </div>
 
20
  disabled,
21
  isRunning,
22
  disabledReason,
23
+ disabledTooltip,
24
  activeQuestion,
25
  }, ref) {
26
  const demoIndexRef = useRef(0);
 
69
  };
70
 
71
  const handleKeyDown = (e) => {
72
+ if (mode !== 'demo') return;
73
  if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
74
  setMode('custom');
75
  setUserText(e.key);
 
91
  };
92
 
93
  const canStart = !disabled && !!resolveQuestion();
94
+ const startButtonTitle = !canStart && disabledTooltip ? disabledTooltip : undefined;
95
 
96
  return (
97
  <div className="chat-controls">
 
112
  </>
113
  ) : (
114
  <>
115
+ <div className="chat-controls-row">
116
+ <input
117
+ type="text"
118
+ className="chat-controls-question"
119
+ value={inputValue}
120
+ placeholder={
121
+ disabled && disabledReason
122
+ ? disabledReason
123
+ : demoQuestions.length === 0
124
+ ? 'Loading demo questions…'
125
+ : ''
 
 
 
126
  }
127
+ onChange={handleChange}
128
+ onKeyDown={(e) => {
129
+ handleKeyDown(e);
130
+ if (e.key === 'Enter' && canStart) {
131
+ e.preventDefault();
132
+ handleStart();
133
+ }
134
+ }}
135
+ />
136
+ <span
137
+ className={
138
+ 'chat-start-btn-wrap'
139
+ + (startButtonTitle ? ' chat-start-btn-wrap-disabled' : '')
140
+ }
141
+ title={startButtonTitle}
142
+ >
143
+ <button
144
+ type="button"
145
+ className="btn-primary chat-controls-start"
146
+ disabled={!canStart}
147
+ onClick={handleStart}
148
+ aria-disabled={!canStart}
149
+ >
150
+ <Play size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
151
+ Start Chat
152
+ </button>
153
+ </span>
154
+ </div>
155
+ {startButtonTitle && (
156
+ <span className="chat-start-hint" role="status">
157
+ {startButtonTitle}
158
+ </span>
159
+ )}
160
  </>
161
  )}
162
  </div>
frontend/src/components/ExpertPersonaModal.js CHANGED
@@ -297,38 +297,60 @@ export default function ExpertPersonaModal({
297
  </div>
298
  </div>
299
 
300
- <div className="ccai-tab-row">
301
- <button
302
- className={'ccai-tab-btn' + (activeTab === 'freeform' ? ' ccai-tab-btn-active' : '')}
303
- onClick={() => setActiveTab('freeform')}
304
- >
305
- Freeform
306
- </button>
307
- <button
308
- className={'ccai-tab-btn' + (activeTab === 'structured' ? ' ccai-tab-btn-active' : '')}
309
- onClick={() => setActiveTab('structured')}
310
- >
311
- Structured
312
- </button>
313
- <div className="ccai-tab-spacer" />
314
- <label className="ccai-role-style">
315
- <input
316
- type="radio"
317
- name="role-style"
318
- checked={roleStyle === 'ai_completed'}
319
- onChange={() => setRoleStyle('ai_completed')}
320
- />
321
- AI-completed
322
- </label>
323
- <label className="ccai-role-style">
324
- <input
325
- type="radio"
326
- name="role-style"
327
- checked={roleStyle === 'exact'}
328
- onChange={() => setRoleStyle('exact')}
329
- />
330
- Exact (no inferring)
331
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  </div>
333
 
334
  {activeTab === 'freeform' ? (
 
297
  </div>
298
  </div>
299
 
300
+ <div className="ccai-expert-input-mode">
301
+ <div className="ccai-tab-row">
302
+ <button
303
+ type="button"
304
+ className={'ccai-tab-btn' + (activeTab === 'freeform' ? ' ccai-tab-btn-active' : '')}
305
+ onClick={() => setActiveTab('freeform')}
306
+ title="One text area for background, bio, and writing samples."
307
+ >
308
+ Freeform
309
+ </button>
310
+ <button
311
+ type="button"
312
+ className={'ccai-tab-btn' + (activeTab === 'structured' ? ' ccai-tab-btn-active' : '')}
313
+ onClick={() => setActiveTab('structured')}
314
+ title="Separate fields for identity, profile, and writing samples."
315
+ >
316
+ Structured
317
+ </button>
318
+ </div>
319
+ <p className="ccai-expert-field-hint">
320
+ Freeform = one box for everything; Structured = separate fields for identity, profile, and samples.
321
+ </p>
322
+ </div>
323
+
324
+ <div className="ccai-expert-role-style">
325
+ <div className="ccai-tab-row ccai-tab-row-compact">
326
+ <label
327
+ className="ccai-role-style"
328
+ title="Fills in tone and style from your notes; stays within what you described."
329
+ >
330
+ <input
331
+ type="radio"
332
+ name="role-style"
333
+ checked={roleStyle === 'ai_completed'}
334
+ onChange={() => setRoleStyle('ai_completed')}
335
+ />
336
+ AI-completed
337
+ </label>
338
+ <label
339
+ className="ccai-role-style"
340
+ title="Reorganizes your text only; won't add traits or facts you didn't provide."
341
+ >
342
+ <input
343
+ type="radio"
344
+ name="role-style"
345
+ checked={roleStyle === 'exact'}
346
+ onChange={() => setRoleStyle('exact')}
347
+ />
348
+ Exact (no inferring)
349
+ </label>
350
+ </div>
351
+ <p className="ccai-expert-field-hint">
352
+ AI-completed fills in tone and style from your notes (no new facts); Exact uses only what you typed.
353
+ </p>
354
  </div>
355
 
356
  {activeTab === 'freeform' ? (
frontend/src/components/ParticipantDropdown.js CHANGED
@@ -5,7 +5,7 @@ import { Users, Plus, ChevronDown, Wand2, Check } from 'lucide-react';
5
  * Header dropdown that lists every available participant the user can
6
  * pull into the conversation. Three sections:
7
  * - Neon (HANA personas, vanilla/RAG already filtered server-side)
8
- * - Extra (the four bundled non-Neon-LLM personas)
9
  * - Expert (user-created, stored in localStorage)
10
  *
11
  * A top-of-list "Select N Automatically" toggle defers the choice to
 
5
  * Header dropdown that lists every available participant the user can
6
  * pull into the conversation. Three sections:
7
  * - Neon (HANA personas, vanilla/RAG already filtered server-side)
8
+ * - Extra (bundled catalog personas, provider + Neon LLMs)
9
  * - Expert (user-created, stored in localStorage)
10
  *
11
  * A top-of-list "Select N Automatically" toggle defers the choice to
frontend/src/components/ParticipantSidebar.js CHANGED
@@ -69,6 +69,7 @@ export default function ParticipantSidebar({
69
 
70
  function ParticipantCard({ participant, enabled, modelOverride, neonPromptByModelId, onToggleEnabled, onRemove }) {
71
  const [open, setOpen] = useState(false);
 
72
  const isHuman = participant.kind === 'human';
73
 
74
  const effectiveModelId = modelOverride
@@ -79,6 +80,16 @@ function ParticipantCard({ participant, enabled, modelOverride, neonPromptByMode
79
  || participant.role_prompt
80
  || '';
81
 
 
 
 
 
 
 
 
 
 
 
82
  return (
83
  <div
84
  className={
@@ -87,15 +98,27 @@ function ParticipantCard({ participant, enabled, modelOverride, neonPromptByMode
87
  + (isHuman ? ' ccai-participant-card-human' : '')
88
  }
89
  >
90
- <div className="ccai-participant-row">
 
 
 
 
 
91
  <button
92
  className="ccai-accordion-chevron"
93
- onClick={() => setOpen(o => !o)}
94
  aria-label="Toggle participant details"
 
95
  >
96
  {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
97
  </button>
98
- <div className="ccai-participant-name">
 
 
 
 
 
 
99
  {isHuman && (
100
  <User
101
  size={12}
@@ -161,9 +184,25 @@ function ParticipantCard({ participant, enabled, modelOverride, neonPromptByMode
161
  </div>
162
  <div className="ccai-participant-field">
163
  <div className="ccai-participant-field-label">Persona prompt</div>
164
- <pre className="ccai-participant-prompt">
 
 
 
 
 
 
 
165
  {personaPrompt || '(no prompt set)'}
166
  </pre>
 
 
 
 
 
 
 
 
 
167
  </div>
168
  </>
169
  )}
 
69
 
70
  function ParticipantCard({ participant, enabled, modelOverride, neonPromptByModelId, onToggleEnabled, onRemove }) {
71
  const [open, setOpen] = useState(false);
72
+ const [promptExpanded, setPromptExpanded] = useState(false);
73
  const isHuman = participant.kind === 'human';
74
 
75
  const effectiveModelId = modelOverride
 
80
  || participant.role_prompt
81
  || '';
82
 
83
+ const PROMPT_PREVIEW_CHARS = 280;
84
+ const promptIsLong = personaPrompt.length > PROMPT_PREVIEW_CHARS;
85
+
86
+ const handleToggleOpen = () => {
87
+ setOpen((wasOpen) => {
88
+ if (wasOpen) setPromptExpanded(false);
89
+ return !wasOpen;
90
+ });
91
+ };
92
+
93
  return (
94
  <div
95
  className={
 
98
  + (isHuman ? ' ccai-participant-card-human' : '')
99
  }
100
  >
101
+ <div
102
+ className={
103
+ 'ccai-participant-row'
104
+ + (open ? ' ccai-participant-row--expanded' : '')
105
+ }
106
+ >
107
  <button
108
  className="ccai-accordion-chevron"
109
+ onClick={handleToggleOpen}
110
  aria-label="Toggle participant details"
111
+ aria-expanded={open}
112
  >
113
  {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
114
  </button>
115
+ <div
116
+ className={
117
+ 'ccai-participant-name'
118
+ + (open ? ' ccai-participant-name--expanded' : '')
119
+ }
120
+ title={!open ? participant.name : undefined}
121
+ >
122
  {isHuman && (
123
  <User
124
  size={12}
 
184
  </div>
185
  <div className="ccai-participant-field">
186
  <div className="ccai-participant-field-label">Persona prompt</div>
187
+ <pre
188
+ className={
189
+ 'ccai-participant-prompt'
190
+ + (promptIsLong && !promptExpanded
191
+ ? ' ccai-participant-prompt--preview'
192
+ : '')
193
+ }
194
+ >
195
  {personaPrompt || '(no prompt set)'}
196
  </pre>
197
+ {promptIsLong && (
198
+ <button
199
+ type="button"
200
+ className="ccai-participant-prompt-toggle"
201
+ onClick={() => setPromptExpanded(v => !v)}
202
+ >
203
+ {promptExpanded ? 'Show less' : 'Show full prompt'}
204
+ </button>
205
+ )}
206
  </div>
207
  </>
208
  )}
frontend/src/styles/ccai.css CHANGED
@@ -165,6 +165,14 @@
165
  gap: 6px;
166
  }
167
 
 
 
 
 
 
 
 
 
168
  .ccai-participant-row-secondary {
169
  justify-content: flex-end;
170
  }
@@ -199,6 +207,14 @@
199
  min-width: 0;
200
  }
201
 
 
 
 
 
 
 
 
 
202
  .ccai-participant-controls {
203
  display: flex;
204
  align-items: center;
@@ -244,8 +260,31 @@
244
  padding: 8px 10px;
245
  white-space: pre-wrap;
246
  word-break: break-word;
247
- max-height: 160px;
248
- overflow-y: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  }
250
 
251
  /* On/off slider switch */
@@ -405,6 +444,25 @@
405
  padding-bottom: 6px;
406
  }
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  .ccai-tab-btn {
409
  padding: 6px 12px;
410
  border: 1px solid transparent;
@@ -894,6 +952,10 @@
894
 
895
  .ccai-orchestrator-msg-body p:last-child { margin-bottom: 0; }
896
 
 
 
 
 
897
  .ccai-orchestrator-msg-report {
898
  background: var(--accent-light);
899
  border-style: solid;
 
165
  gap: 6px;
166
  }
167
 
168
+ .ccai-participant-row--expanded {
169
+ align-items: flex-start;
170
+ }
171
+
172
+ .ccai-participant-row--expanded .ccai-accordion-chevron {
173
+ margin-top: 1px;
174
+ }
175
+
176
  .ccai-participant-row-secondary {
177
  justify-content: flex-end;
178
  }
 
207
  min-width: 0;
208
  }
209
 
210
+ .ccai-participant-name--expanded {
211
+ white-space: normal;
212
+ overflow: visible;
213
+ text-overflow: unset;
214
+ line-height: 1.35;
215
+ word-break: break-word;
216
+ }
217
+
218
  .ccai-participant-controls {
219
  display: flex;
220
  align-items: center;
 
260
  padding: 8px 10px;
261
  white-space: pre-wrap;
262
  word-break: break-word;
263
+ }
264
+
265
+ .ccai-participant-prompt--preview {
266
+ display: -webkit-box;
267
+ -webkit-box-orient: vertical;
268
+ -webkit-line-clamp: 5;
269
+ overflow: hidden;
270
+ }
271
+
272
+ .ccai-participant-prompt-toggle {
273
+ align-self: flex-start;
274
+ margin: 0;
275
+ padding: 0;
276
+ border: none;
277
+ background: none;
278
+ font-size: 11px;
279
+ font-weight: 600;
280
+ color: var(--accent-primary, #6366f1);
281
+ cursor: pointer;
282
+ text-decoration: underline;
283
+ text-underline-offset: 2px;
284
+ }
285
+
286
+ .ccai-participant-prompt-toggle:hover {
287
+ color: var(--text-primary);
288
  }
289
 
290
  /* On/off slider switch */
 
444
  padding-bottom: 6px;
445
  }
446
 
447
+ .ccai-tab-row-compact {
448
+ border-bottom: none;
449
+ padding-bottom: 0;
450
+ }
451
+
452
+ .ccai-expert-input-mode,
453
+ .ccai-expert-role-style {
454
+ display: flex;
455
+ flex-direction: column;
456
+ gap: 6px;
457
+ }
458
+
459
+ .ccai-expert-field-hint {
460
+ margin: 0;
461
+ font-size: 11px;
462
+ line-height: 1.45;
463
+ color: var(--text-tertiary);
464
+ }
465
+
466
  .ccai-tab-btn {
467
  padding: 6px 12px;
468
  border: 1px solid transparent;
 
952
 
953
  .ccai-orchestrator-msg-body p:last-child { margin-bottom: 0; }
954
 
955
+ .ccai-orchestrator-msg-body :where(p, li, ol, ul, blockquote, td, th) {
956
+ color: inherit;
957
+ }
958
+
959
  .ccai-orchestrator-msg-report {
960
  background: var(--accent-light);
961
  border-style: solid;
frontend/src/styles/components.css CHANGED
@@ -445,16 +445,75 @@
445
  gap: 16px;
446
  padding: 16px 0;
447
  overflow-y: auto;
 
448
  }
449
 
450
  .chat-empty {
451
- flex: 1;
452
  display: flex;
 
453
  align-items: center;
454
- justify-content: center;
 
 
 
 
455
  color: var(--text-muted);
456
  font-size: 15px;
457
  font-style: italic;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  }
459
 
460
  /* ── Message Bubbles ───────────────────────────────────────────── */
@@ -514,6 +573,27 @@
514
  word-break: break-word;
515
  }
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  .message-bubble.bubble-a {
518
  background: var(--speaker-a-bg);
519
  color: var(--text-primary);
@@ -593,38 +673,109 @@
593
  /* Download strip rendered below the "End of Chat" marker. Mirrors the
594
  items in the header DownloadMenu so the user doesn't have to scroll
595
  back up to the top of a finished conversation to export it. */
 
 
 
 
 
 
 
 
 
596
  .chat-end-downloads {
597
  display: flex;
598
  flex-wrap: wrap;
599
  justify-content: center;
600
  gap: 8px;
601
- padding: 12px 0 4px;
602
  }
603
 
604
  .chat-end-download-btn {
605
  white-space: nowrap;
606
  }
607
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  /* ── Chat Controls ─────────────────────────────────────────────── */
609
 
610
  .chat-controls {
611
  display: flex;
612
- gap: 12px;
613
  align-items: stretch;
 
 
614
  padding: 12px 0;
 
615
  }
616
 
617
- .chat-controls input {
618
- flex: 1;
619
- padding: 10px 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  border: 1px solid var(--border-primary);
621
  border-radius: 8px;
622
  background: var(--bg-primary);
623
  color: var(--text-primary);
624
- font-size: 14px;
 
 
 
625
  }
626
 
627
- .chat-controls input:focus {
 
 
 
 
 
 
628
  outline: none;
629
  border-color: var(--accent-primary);
630
  box-shadow: 0 0 0 3px var(--accent-light);
@@ -1216,11 +1367,9 @@
1216
  padding: 12px 16px 16px;
1217
  }
1218
  .chat-controls {
1219
- flex-wrap: wrap;
1220
  gap: 8px;
1221
  }
1222
- .chat-controls input {
1223
- flex-basis: 100%;
1224
  font-size: 13px;
1225
  }
1226
  .message-row {
@@ -1334,11 +1483,19 @@
1334
  }
1335
  /* On phones the download strip stacks so each label is fully
1336
  readable; on wider viewports it stays inline-wrap. */
 
 
 
 
 
1337
  .chat-end-downloads {
1338
  flex-direction: column;
1339
  align-items: stretch;
1340
  gap: 6px;
1341
  }
 
 
 
1342
  .chat-end-download-btn {
1343
  justify-content: center;
1344
  }
 
445
  gap: 16px;
446
  padding: 16px 0;
447
  overflow-y: auto;
448
+ min-height: 0;
449
  }
450
 
451
  .chat-empty {
452
+ flex: 1 1 auto;
453
  display: flex;
454
+ flex-direction: column;
455
  align-items: center;
456
+ justify-content: flex-start;
457
+ width: 100%;
458
+ min-height: min-content;
459
+ gap: 1.5rem;
460
+ padding: 0.25rem 0 1.5rem;
461
  color: var(--text-muted);
462
  font-size: 15px;
463
  font-style: italic;
464
+ pointer-events: none;
465
+ }
466
+
467
+ .chat-empty-copy {
468
+ display: flex;
469
+ flex-direction: column;
470
+ align-items: center;
471
+ text-align: center;
472
+ gap: 1.25rem;
473
+ flex-shrink: 0;
474
+ }
475
+
476
+ .chat-empty-lead,
477
+ .chat-empty-instruction {
478
+ margin: 0;
479
+ white-space: nowrap;
480
+ }
481
+
482
+ .chat-empty-lead {
483
+ font-weight: 700;
484
+ }
485
+
486
+ .chat-empty-sample {
487
+ width: min(100%, 42rem);
488
+ flex-shrink: 0;
489
+ opacity: 0.72;
490
+ border: 1px dashed var(--border-primary);
491
+ border-radius: 12px;
492
+ background: var(--bg-secondary);
493
+ padding: 12px 14px 10px;
494
+ font-style: normal;
495
+ }
496
+
497
+ .chat-empty-sample-label {
498
+ margin: 0 0 10px;
499
+ font-size: 11px;
500
+ font-weight: 600;
501
+ font-style: normal;
502
+ letter-spacing: 0.04em;
503
+ text-transform: uppercase;
504
+ text-align: center;
505
+ color: var(--text-tertiary);
506
+ }
507
+
508
+ .chat-empty-sample-panel {
509
+ display: flex;
510
+ flex-direction: column;
511
+ gap: 4px;
512
+ }
513
+
514
+ .chat-empty-sample-panel .message-row,
515
+ .chat-empty-sample-panel .ccai-orchestrator-msg {
516
+ animation: none;
517
  }
518
 
519
  /* ── Message Bubbles ───────────────────────────────────────────── */
 
573
  word-break: break-word;
574
  }
575
 
576
+ /* Live participant bubbles use light pastel backgrounds (set inline in
577
+ MessageBubble.js). Keep body text dark so it stays readable in both
578
+ light and dark app themes — do not inherit --text-primary, which is
579
+ light-colored in dark mode. */
580
+ .ccai-bubble {
581
+ color: #111827;
582
+ font-style: normal;
583
+ }
584
+
585
+ .ccai-bubble :where(p, li, ol, ul, blockquote, td, th, h1, h2, h3, h4, h5, h6) {
586
+ color: inherit;
587
+ }
588
+
589
+ .ccai-bubble a {
590
+ color: #4338CA;
591
+ }
592
+
593
+ .ccai-bubble .message-elapsed {
594
+ color: #6B7280;
595
+ }
596
+
597
  .message-bubble.bubble-a {
598
  background: var(--speaker-a-bg);
599
  color: var(--text-primary);
 
673
  /* Download strip rendered below the "End of Chat" marker. Mirrors the
674
  items in the header DownloadMenu so the user doesn't have to scroll
675
  back up to the top of a finished conversation to export it. */
676
+ .chat-end-bar {
677
+ display: flex;
678
+ flex-wrap: wrap;
679
+ justify-content: center;
680
+ align-items: center;
681
+ gap: 12px 20px;
682
+ padding: 12px 0 4px;
683
+ }
684
+
685
  .chat-end-downloads {
686
  display: flex;
687
  flex-wrap: wrap;
688
  justify-content: center;
689
  gap: 8px;
 
690
  }
691
 
692
  .chat-end-download-btn {
693
  white-space: nowrap;
694
  }
695
 
696
+ .chat-end-cta {
697
+ margin: 0;
698
+ font-size: 13px;
699
+ line-height: 1.45;
700
+ color: var(--text-secondary);
701
+ text-align: center;
702
+ max-width: 20rem;
703
+ }
704
+
705
+ .chat-end-cta a {
706
+ color: var(--accent-primary);
707
+ font-weight: 600;
708
+ text-decoration: none;
709
+ }
710
+
711
+ .chat-end-cta a:hover {
712
+ text-decoration: underline;
713
+ }
714
+
715
  /* ── Chat Controls ─────────────────────────────────────────────── */
716
 
717
  .chat-controls {
718
  display: flex;
719
+ flex-direction: column;
720
  align-items: stretch;
721
+ gap: 6px;
722
+ width: 100%;
723
  padding: 12px 0;
724
+ flex-shrink: 0;
725
  }
726
 
727
+ .chat-controls-row {
728
+ display: flex;
729
+ flex-wrap: nowrap;
730
+ align-items: stretch;
731
+ gap: 8px;
732
+ width: 100%;
733
+ min-width: 0;
734
+ }
735
+
736
+ .chat-start-btn-wrap {
737
+ display: inline-flex;
738
+ flex: 0 0 auto;
739
+ align-self: stretch;
740
+ }
741
+
742
+ .chat-start-btn-wrap-disabled {
743
+ cursor: not-allowed;
744
+ }
745
+
746
+ .chat-start-btn-wrap button:disabled {
747
+ pointer-events: none;
748
+ }
749
+
750
+ .chat-start-hint {
751
+ font-size: 11px;
752
+ line-height: 1.3;
753
+ color: var(--text-secondary);
754
+ text-align: right;
755
+ }
756
+
757
+ .chat-controls-question {
758
+ flex: 1 1 0;
759
+ min-width: 0;
760
+ width: 0;
761
+ padding: 9px 12px;
762
  border: 1px solid var(--border-primary);
763
  border-radius: 8px;
764
  background: var(--bg-primary);
765
  color: var(--text-primary);
766
+ font-size: clamp(12px, calc(0.62vw + 5px), 14px);
767
+ line-height: 1.25;
768
+ font-family: inherit;
769
+ letter-spacing: -0.01em;
770
  }
771
 
772
+ .chat-controls-start {
773
+ height: 100%;
774
+ padding: 0 16px;
775
+ white-space: nowrap;
776
+ }
777
+
778
+ .chat-controls-question:focus {
779
  outline: none;
780
  border-color: var(--accent-primary);
781
  box-shadow: 0 0 0 3px var(--accent-light);
 
1367
  padding: 12px 16px 16px;
1368
  }
1369
  .chat-controls {
 
1370
  gap: 8px;
1371
  }
1372
+ .chat-controls-question {
 
1373
  font-size: 13px;
1374
  }
1375
  .message-row {
 
1483
  }
1484
  /* On phones the download strip stacks so each label is fully
1485
  readable; on wider viewports it stays inline-wrap. */
1486
+ .chat-end-bar {
1487
+ flex-direction: column;
1488
+ align-items: stretch;
1489
+ gap: 10px;
1490
+ }
1491
  .chat-end-downloads {
1492
  flex-direction: column;
1493
  align-items: stretch;
1494
  gap: 6px;
1495
  }
1496
+ .chat-end-cta {
1497
+ max-width: none;
1498
+ }
1499
  .chat-end-download-btn {
1500
  justify-content: center;
1501
  }
frontend/src/styles/layout.css CHANGED
@@ -117,7 +117,7 @@
117
  flex: 1;
118
  min-height: 0;
119
  padding: 20px 24px;
120
- overflow-y: auto;
121
  display: flex;
122
  flex-direction: column;
123
  gap: 0;
 
117
  flex: 1;
118
  min-height: 0;
119
  padding: 20px 24px;
120
+ overflow: hidden;
121
  display: flex;
122
  flex-direction: column;
123
  gap: 0;
frontend/src/utils/storage.js CHANGED
@@ -9,11 +9,51 @@
9
  const NS = 'ccai-vibe-demo';
10
  const SCHEMA_VERSION = 1;
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  const DEFAULTS = {
13
  schema_version: SCHEMA_VERSION,
14
  expert_personas: [],
15
- participants_selected: [],
16
- participants_enabled: {},
17
  model_assignments: {},
18
  orchestrator_model_id: null,
19
  summarizer_model_id: null,
 
9
  const NS = 'ccai-vibe-demo';
10
  const SCHEMA_VERSION = 1;
11
 
12
+ /** Demo trio — ids/names mirror backend/app/services/extra_personas.py */
13
+ export const DEFAULT_DEMO_PERSONAS = [
14
+ {
15
+ participant_id: 'extra_elena_financial_strategist',
16
+ name: 'Elena — Financial Strategist',
17
+ },
18
+ {
19
+ participant_id: 'extra_marcus_technology_strategist',
20
+ name: 'Marcus — Technology Strategist',
21
+ },
22
+ {
23
+ participant_id: 'extra_amira_security_advisor',
24
+ name: 'Dr. Amira — Security & Privacy Advisor',
25
+ },
26
+ ];
27
+
28
+ /** Default panel for first load / empty saved selection (demo trio). */
29
+ export const DEFAULT_PARTICIPANT_IDS = DEFAULT_DEMO_PERSONAS.map(
30
+ (p) => p.participant_id,
31
+ );
32
+
33
+ export const DEFAULT_PARTICIPANTS_ENABLED = Object.fromEntries(
34
+ DEFAULT_PARTICIPANT_IDS.map((id) => [id, true]),
35
+ );
36
+
37
+ /** Use saved selection when non-empty; otherwise the demo default trio. */
38
+ export function resolveInitialParticipants(persisted) {
39
+ const saved = persisted?.participants_selected;
40
+ if (Array.isArray(saved) && saved.length > 0) {
41
+ return {
42
+ selectedIds: saved,
43
+ enabledMap: persisted.participants_enabled || {},
44
+ };
45
+ }
46
+ return {
47
+ selectedIds: [...DEFAULT_PARTICIPANT_IDS],
48
+ enabledMap: { ...DEFAULT_PARTICIPANTS_ENABLED },
49
+ };
50
+ }
51
+
52
  const DEFAULTS = {
53
  schema_version: SCHEMA_VERSION,
54
  expert_personas: [],
55
+ participants_selected: [...DEFAULT_PARTICIPANT_IDS],
56
+ participants_enabled: { ...DEFAULT_PARTICIPANTS_ENABLED },
57
  model_assignments: {},
58
  orchestrator_model_id: null,
59
  summarizer_model_id: null,
mock_backend.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mock CCAI backend for UI rendering/screenshots ONLY.
2
+
3
+ Serves the exact JSON shapes the frontend expects (derived from the real
4
+ backend's api/personas.py, api/models.py, extra_personas.py, demo_questions.json)
5
+ plus a canned SSE panel for /api/chat/start so the multi-persona discussion UI
6
+ can be screenshotted without any real LLM/HANA access. Not used in production.
7
+ """
8
+ import json, time
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+
11
+ PROVIDERS = [
12
+ {"id": "openai", "name": "OpenAI", "models": [
13
+ {"id": "gpt-5.4", "name": "GPT-5.4", "params": "Undisclosed"},
14
+ {"id": "gpt-4.1", "name": "GPT-4.1", "params": "Undisclosed"},
15
+ {"id": "gpt-4o", "name": "GPT-4o", "params": "~200B"},
16
+ {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "params": "~8B"},
17
+ {"id": "o4-mini", "name": "o4-Mini", "params": "Undisclosed"},
18
+ ]},
19
+ {"id": "gemini", "name": "Google Gemini", "models": [
20
+ {"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash", "params": "Undisclosed"},
21
+ {"id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro", "params": "Undisclosed"},
22
+ ]},
23
+ {"id": "mistral", "name": "Mistral", "models": [
24
+ {"id": "devstral-2512", "name": "Devstral2", "params": "123B"},
25
+ {"id": "mistral-small-2603", "name": "Mistral Small 4", "params": "119B"},
26
+ ]},
27
+ {"id": "meta", "name": "Meta Llama", "models": [
28
+ {"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "name": "Llama 3.3 70B Turbo", "params": "70B"},
29
+ ]},
30
+ {"id": "deepseek", "name": "DeepSeek", "models": [
31
+ {"id": "accounts/fireworks/models/deepseek-v3p1", "name": "DeepSeek V3.1", "params": "671B"},
32
+ ]},
33
+ ]
34
+
35
+ NEON_MODELS = [
36
+ {"model_id": "BrainForge/Security@2026.03.18", "name": "BrainForge/Security",
37
+ "personas": [
38
+ {"persona_name": "Athena", "enabled": True, "system_prompt": "You are Athena, a strategic advisor."},
39
+ {"persona_name": "Vanilla", "enabled": True, "system_prompt": "Plain assistant."},
40
+ ]},
41
+ ]
42
+
43
+ NEON_PERSONAS = [
44
+ {"participant_id": "neon:BrainForge/Security@2026.03.18:Athena", "kind": "neon",
45
+ "name": "Athena (Strategic Advisor)", "model_display": "Security",
46
+ "default_model_id": "neon:BrainForge/Security@2026.03.18:Athena",
47
+ "description": "Strategic advisor persona", "role_prompt": "You are Athena."},
48
+ ]
49
+
50
+ EXTRA = [
51
+ {"participant_id": "extra_pragmatic_generalist", "name": "Pragmatic Finance Expert",
52
+ "default_model_id": "gpt-5.4", "model_display": "gpt-5.4", "kind": "extra", "role_prompt": "..."},
53
+ {"participant_id": "extra_skeptical_critic", "name": "Skeptical Philosopher",
54
+ "default_model_id": "gemini-2.5-flash", "model_display": "gemini-2.5-flash", "kind": "extra", "role_prompt": "..."},
55
+ {"participant_id": "extra_empathetic_humanist", "name": "Empathetic Historian",
56
+ "default_model_id": "devstral-2512", "model_display": "devstral-2512", "kind": "extra", "role_prompt": "..."},
57
+ {"participant_id": "extra_data_driven_analyst", "name": "Data-Driven Geologist",
58
+ "default_model_id": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
59
+ "model_display": "Llama-3.3-70B-Instruct-Turbo", "kind": "extra", "role_prompt": "..."},
60
+ {"participant_id": "extra_elena_financial_strategist", "name": "Elena — Financial Strategist",
61
+ "default_model_id": "gpt-4.1", "model_display": "gpt-4.1", "kind": "extra", "role_prompt": "..."},
62
+ {"participant_id": "extra_marcus_technology_strategist", "name": "Marcus — Technology Strategist",
63
+ "default_model_id": "mistral-small-2603",
64
+ "model_display": "mistral-small-2603", "kind": "extra", "role_prompt": "..."},
65
+ {"participant_id": "extra_amira_security_advisor", "name": "Dr. Amira — Security & Privacy Advisor",
66
+ "default_model_id": "neon:BrainForge/Security@2026.05.13:CybersecurityExpert",
67
+ "model_display": "CybersecurityExpert", "kind": "extra", "role_prompt": "..."},
68
+ ]
69
+
70
+ DEMO_QUESTIONS = json.load(open("backend/app/data/demo_questions.json"))["questions"]
71
+
72
+ FORMATS = {
73
+ "structures": [
74
+ {"id": "collaborative", "name": "Collaborative Discussion", "description": "Structured group reasoning toward consensus."},
75
+ {"id": "roberts_rules", "name": "Robert's Rules", "description": "Formal motion/second/vote procedure."},
76
+ ],
77
+ "decisions": [
78
+ {"id": "consensus", "name": "Consensus", "description": "Seek agreement; majority report with dissent."},
79
+ {"id": "majority", "name": "Majority Vote", "description": "Simple majority."},
80
+ {"id": "ranked_choice", "name": "Ranked Choice", "description": "Ranked-choice tally."},
81
+ ],
82
+ "default_structure_id": "collaborative",
83
+ "default_decision_id": "consensus",
84
+ }
85
+
86
+ def sse(event, data):
87
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
88
+
89
+ class H(BaseHTTPRequestHandler):
90
+ def log_message(self, *a): pass
91
+ def _json(self, obj, code=200):
92
+ body = json.dumps(obj).encode()
93
+ self.send_response(code)
94
+ self.send_header("Content-Type", "application/json")
95
+ self.send_header("Access-Control-Allow-Origin", "*")
96
+ self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
97
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
98
+ self.send_header("Cache-Control", "no-store")
99
+ self.end_headers()
100
+ self.wfile.write(body)
101
+ def do_OPTIONS(self):
102
+ self.send_response(204)
103
+ self.send_header("Access-Control-Allow-Origin", "*")
104
+ self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
105
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
106
+ self.end_headers()
107
+ def do_GET(self):
108
+ p = self.path.split("?")[0]
109
+ if p == "/api/models": return self._json({"neon_models": NEON_MODELS, "providers": PROVIDERS})
110
+ if p == "/api/personas": return self._json({"neon": NEON_PERSONAS, "extra": EXTRA})
111
+ if p == "/api/demo-questions": return self._json({"questions": DEMO_QUESTIONS})
112
+ if p == "/api/chat/orchestrator": return self._json({"model_id": "gpt-4o-mini"})
113
+ if p == "/api/chat/speed-priority": return self._json({"enabled": False})
114
+ if p == "/api/chat/conversation-formats": return self._json(FORMATS)
115
+ if p == "/api/auth/status": return self._json({"logged_in": False, "is_org_member": False, "remaining_conversations": 30})
116
+ if p == "/api/rate-limit/status": return self._json({"remaining": 30, "daily_limit": 30})
117
+ return self._json({}, 404)
118
+ def do_PUT(self):
119
+ self._read()
120
+ if self.path.startswith("/api/chat/orchestrator"): return self._json({"model_id": "gpt-4o-mini"})
121
+ if self.path.startswith("/api/chat/speed-priority"): return self._json({"enabled": False})
122
+ return self._json({})
123
+ def do_PATCH(self):
124
+ self._read(); return self._json({})
125
+ def _read(self):
126
+ n = int(self.headers.get("Content-Length", 0) or 0)
127
+ return self.rfile.read(n) if n else b""
128
+ def do_POST(self):
129
+ raw = self._read()
130
+ try: body = json.loads(raw or b"{}")
131
+ except Exception: body = {}
132
+ if self.path.startswith("/api/chat/suggest-model"):
133
+ return self._json({"recommended_model_id": "gemini-2.5-flash",
134
+ "rationale": "A philosophy-leaning critic benefits from a model with strong reasoning and concise argumentation; Gemini 2.5 Flash also diversifies the panel away from the GPT family already present."})
135
+ if self.path.startswith("/api/chat/generate-role-freeform") or self.path.startswith("/api/chat/generate-role"):
136
+ return self._json({"role_prompt": "You are " + (body.get("name") or "an expert") + ". (mock-generated role prompt for UI rendering)"})
137
+ if self.path.startswith("/api/chat/auto-select-participants"):
138
+ cands = body.get("candidates", [])
139
+ return self._json({"selected": [c["participant_id"] for c in cands[:body.get("count",3)]],
140
+ "rationale": "Picked the most topically relevant participants (mock)."})
141
+ if self.path.startswith("/api/chat/start"):
142
+ return self._sse_panel(body)
143
+ return self._json({})
144
+ def _sse_panel(self, body):
145
+ self.send_response(200)
146
+ self.send_header("Content-Type", "text/event-stream")
147
+ self.send_header("Access-Control-Allow-Origin", "*")
148
+ self.send_header("Cache-Control", "no-cache")
149
+ self.end_headers()
150
+ parts = body.get("participants", [])
151
+ if len(parts) < 2:
152
+ parts = [{"participant_id": "extra_pragmatic_generalist", "name": "Pragmatic Finance Expert"},
153
+ {"participant_id": "extra_skeptical_critic", "name": "Skeptical Philosopher"},
154
+ {"participant_id": "extra_empathetic_humanist", "name": "Empathetic Historian"}]
155
+ q = body.get("question", "the question")
156
+ sid = "mock-" + str(int(time.time()))
157
+ roster = [{"participant_id": p["participant_id"], "name": p["name"],
158
+ "model_display": p.get("name")} for p in parts]
159
+ self.wfile.write(sse("session", {"session_id": sid, "participants": roster})); self.wfile.flush()
160
+ self.wfile.write(sse("status", {"message": "Phase 1: Initial Opinions"})); self.wfile.flush()
161
+ op = {
162
+ parts[0]["name"]: f"On '{q[:50]}...', my first take is to weigh cost against benefit. The numbers have to clear a return-on-investment bar before anything else.",
163
+ parts[1]["name"]: "I'd challenge the framing. Before we optimize, what assumption are we all making that nobody has examined? Let's pressure-test the premise.",
164
+ }
165
+ if len(parts) > 2:
166
+ op[parts[2]["name"]] = "I want to center the human stakes. Whoever is on the receiving end of this decision matters as much as the spreadsheet."
167
+ for i, p in enumerate(parts):
168
+ mid = f"m{i}"
169
+ txt = op.get(p["name"], "Here is my initial opinion, grounded in my area of expertise.")
170
+ self.wfile.write(sse("message", {"message_id": mid, "role": "participant",
171
+ "speaker_id": p["participant_id"], "speaker_name": p["name"],
172
+ "text": txt, "phase": "initial_opinions", "elapsed_seconds": 2.1 + i,
173
+ "timestamp": time.time()})); self.wfile.flush()
174
+ self.wfile.write(sse("orchestrator", {"message_id": "o1", "role": "orchestrator",
175
+ "kind": "status", "text": "All participants have given initial opinions. Moving to the critique phase, where each will respond to the others.",
176
+ "timestamp": time.time()})); self.wfile.flush()
177
+ self.wfile.write(sse("message", {"message_id": "m10", "role": "participant",
178
+ "speaker_id": parts[1]["participant_id"], "speaker_name": parts[1]["name"],
179
+ "text": "Responding directly to the finance framing: ROI is necessary but not sufficient. A positive ROI on paper can still be the wrong call if it erodes trust.",
180
+ "addressed_to": parts[0]["participant_id"], "replying_to": [parts[0]["participant_id"]],
181
+ "phase": "critique", "elapsed_seconds": 3.4, "timestamp": time.time()})); self.wfile.flush()
182
+ self.wfile.write(sse("orchestrator", {"message_id": "o2", "role": "orchestrator",
183
+ "kind": "majority_report",
184
+ "text": "**Majority Report.** The panel converges on a staged approach: validate ROI on a small pilot first, while explicitly protecting the human/trust factors the Historian raised. One participant dissents, preferring a faster full commitment.",
185
+ "timestamp": time.time()})); self.wfile.flush()
186
+ self.wfile.write(sse("system", {"text": "End of Chat"})); self.wfile.flush()
187
+ self.wfile.write(sse("done", {})); self.wfile.flush()
188
+
189
+ if __name__ == "__main__":
190
+ print("Mock backend on :8000")
191
+ ThreadingHTTPServer(("127.0.0.1", 8000), H).serve_forever()