|
1 | | -// lib/chat.js — SSE streaming adapter for Dojo Gateway. |
2 | | -// Falls back to a helpful placeholder while the proxy endpoint is being wired. |
| 1 | +// lib/chat.js — Chat adapter for the Dojo Gateway via /v1/chat proxy. |
| 2 | +// The Gateway expects: { message: string, session_id: string, stream: bool } |
| 3 | +// NOT the OpenAI-style { messages: [...] } format. |
3 | 4 | const ChatAdapter = { |
| 5 | + _sessionId: 'pdi-web-' + Date.now().toString(36), |
| 6 | + _proxyAvailable: null, |
4 | 7 |
|
5 | | - // Placeholder responses shown before the Gateway proxy is connected. |
6 | 8 | _placeholders: [ |
7 | | - "The chat interface connects to the Dojo Gateway for AI-powered data analysis. The Gateway proxy endpoint is being wired up — check back soon.", |
8 | | - "While the live gateway connection is being configured, you can explore the data using the Counties, Compare, Evidence, and Analysis tabs.", |
9 | | - "The Policy Data Infrastructure platform tracks 42 indicator variables across 13 federal and state data sources covering all 72 Wisconsin counties and 1,652 census tracts.", |
10 | | - "Try browsing Wisconsin counties at #/counties, or compare two counties side-by-side at #/compare to see detailed indicator differences.", |
11 | | - "The Analysis section (#/analysis) surfaces statistical results including OLS regression coefficients, correlation matrices, and composite disadvantage indices computed across all tracts." |
| 9 | + "The chat interface connects to the Dojo Gateway for AI-powered data analysis. Try asking about Wisconsin counties, poverty rates, health outcomes, or policy positions.", |
| 10 | + "Try: 'What county has the highest poverty rate?' or 'Compare Dane and Milwaukee counties' or 'Tell me about Francesca Hong's housing policies'", |
| 11 | + "The platform tracks 42 indicator variables across 13 data sources covering 72 Wisconsin counties and 1,652 census tracts." |
12 | 12 | ], |
13 | 13 |
|
14 | | - // Detect whether the PDI chat proxy endpoint is available. |
15 | | - _proxyAvailable: null, |
16 | | - |
17 | 14 | async _checkProxy() { |
18 | 15 | if (this._proxyAvailable !== null) return this._proxyAvailable; |
19 | 16 | try { |
20 | 17 | const r = await fetch('/v1/chat', { |
21 | 18 | method: 'POST', |
22 | 19 | headers: { 'Content-Type': 'application/json' }, |
23 | | - body: JSON.stringify({ messages: [{ role: 'user', content: 'ping' }], stream: false }), |
24 | | - signal: AbortSignal.timeout(2000) |
| 20 | + body: JSON.stringify({ message: 'ping', session_id: this._sessionId, stream: false }), |
| 21 | + signal: AbortSignal.timeout(5000) |
25 | 22 | }); |
26 | | - this._proxyAvailable = r.status !== 404 && r.status !== 501; |
| 23 | + // 200 = working, 400 with "message is required" = wrong format, 502 = gateway down |
| 24 | + this._proxyAvailable = r.ok; |
27 | 25 | } catch (_) { |
28 | 26 | this._proxyAvailable = false; |
29 | 27 | } |
30 | 28 | return this._proxyAvailable; |
31 | 29 | }, |
32 | 30 |
|
33 | | - // Send messages and stream the response. |
34 | | - // onChunk(text) called with each chunk; onDone() called when complete. |
35 | | - async send(messages, onChunk, onDone) { |
| 31 | + // Send a message and stream the response. |
| 32 | + // userMessage is the latest user text. conversationHistory is ignored for now |
| 33 | + // (the Gateway manages session state via session_id). |
| 34 | + async send(userMessage, onChunk, onDone) { |
36 | 35 | const available = await this._checkProxy(); |
37 | 36 |
|
38 | 37 | if (available) { |
39 | | - await this._sendToGateway(messages, onChunk, onDone); |
| 38 | + await this._sendToGateway(userMessage, onChunk, onDone); |
40 | 39 | } else { |
41 | | - await this._sendPlaceholder(messages, onChunk, onDone); |
| 40 | + await this._sendPlaceholder(userMessage, onChunk, onDone); |
42 | 41 | } |
43 | 42 | }, |
44 | 43 |
|
45 | | - // Real SSE streaming to the PDI chat proxy (→ Dojo Gateway → Anthropic). |
46 | | - async _sendToGateway(messages, onChunk, onDone) { |
| 44 | + async _sendToGateway(userMessage, onChunk, onDone) { |
47 | 45 | try { |
48 | 46 | const r = await fetch('/v1/chat', { |
49 | 47 | method: 'POST', |
50 | 48 | headers: { 'Content-Type': 'application/json' }, |
51 | | - body: JSON.stringify({ messages, stream: true }) |
| 49 | + body: JSON.stringify({ |
| 50 | + message: userMessage, |
| 51 | + session_id: this._sessionId, |
| 52 | + stream: false // Non-streaming for now — gateway returns complete JSON |
| 53 | + }) |
52 | 54 | }); |
53 | 55 |
|
54 | 56 | if (!r.ok) { |
55 | | - onChunk(`Error: HTTP ${r.status} from chat endpoint.`); |
| 57 | + const errBody = await r.text(); |
| 58 | + onChunk(`Error (${r.status}): ${errBody.substring(0, 200)}`); |
56 | 59 | onDone(); |
57 | 60 | return; |
58 | 61 | } |
59 | 62 |
|
60 | | - const reader = r.body.getReader(); |
61 | | - const decoder = new TextDecoder(); |
62 | | - let buffer = ''; |
| 63 | + const data = await r.json(); |
| 64 | + // Gateway response: { type: "complete", content: "...", usage: {...} } |
| 65 | + const content = data.content || data.message || JSON.stringify(data); |
63 | 66 |
|
64 | | - while (true) { |
65 | | - const { done, value } = await reader.read(); |
66 | | - if (done) break; |
67 | | - |
68 | | - buffer += decoder.decode(value, { stream: true }); |
69 | | - const lines = buffer.split('\n'); |
70 | | - buffer = lines.pop(); // keep incomplete last line |
71 | | - |
72 | | - for (const line of lines) { |
73 | | - if (line.startsWith('data: ')) { |
74 | | - const data = line.slice(6).trim(); |
75 | | - if (data === '[DONE]') { onDone(); return; } |
76 | | - try { |
77 | | - const parsed = JSON.parse(data); |
78 | | - // Anthropic SSE delta format |
79 | | - const delta = parsed?.delta?.text |
80 | | - ?? parsed?.choices?.[0]?.delta?.content |
81 | | - ?? ''; |
82 | | - if (delta) onChunk(delta); |
83 | | - } catch (_) { |
84 | | - // non-JSON SSE line — ignore |
85 | | - } |
86 | | - } |
87 | | - } |
| 67 | + // Simulate streaming for UX consistency |
| 68 | + for (let i = 0; i < content.length; i += 5) { |
| 69 | + onChunk(content.substring(i, Math.min(i + 5, content.length))); |
| 70 | + await new Promise(resolve => setTimeout(resolve, 10)); |
88 | 71 | } |
89 | | - |
90 | 72 | onDone(); |
91 | 73 | } catch (err) { |
92 | 74 | onChunk(`Connection error: ${err.message}`); |
93 | 75 | onDone(); |
94 | 76 | } |
95 | 77 | }, |
96 | 78 |
|
97 | | - // Placeholder fallback — simulates streaming for UX consistency. |
98 | | - async _sendPlaceholder(messages, onChunk, onDone) { |
99 | | - const last = messages[messages.length - 1]?.content?.toLowerCase() || ''; |
| 79 | + async _sendPlaceholder(userMessage, onChunk, onDone) { |
| 80 | + const q = (userMessage || '').toLowerCase(); |
100 | 81 | let response; |
101 | 82 |
|
102 | | - // Simple keyword routing for slightly smarter placeholder responses. |
103 | | - if (last.includes('county') || last.includes('dane') || last.includes('milwaukee')) { |
104 | | - response = "County-level data is available at #/counties. Each county card shows poverty rate, median household income, and uninsured rate. Click any county for a full profile with grouped indicators by health, housing, food access, and demographics."; |
105 | | - } else if (last.includes('tract') || last.includes('census')) { |
106 | | - response = "Census tract data is available for all 1,652 Wisconsin tracts. Navigate to a county profile and click 'View Tracts' to explore tract-level CDC PLACES health outcomes and USDA food access indicators."; |
107 | | - } else if (last.includes('policy') || last.includes('candidate')) { |
108 | | - response = "The platform tracks 85 policy positions from Wisconsin progressive candidates, crosswalked to equity dimensions. Visit #/candidates to filter by candidate or policy category."; |
109 | | - } else if (last.includes('analysis') || last.includes('regression') || last.includes('correlation')) { |
110 | | - response = "The Analysis section (#/analysis) surfaces computed statistical results: OLS regression models, pairwise correlation matrices, composite disadvantage indices, and tipping point analyses across all 1,652 tracts."; |
| 83 | + if (q.includes('county') || q.includes('dane') || q.includes('milwaukee')) { |
| 84 | + response = "County-level data is available at #/counties. Each county shows poverty rate, median household income, and uninsured rate from Census ACS 2023. Click any county for a full profile with indicators grouped by health, housing, food access, and demographics."; |
| 85 | + } else if (q.includes('tract') || q.includes('census')) { |
| 86 | + response = "Census tract data covers 1,652 Wisconsin tracts with CDC PLACES health outcomes (8 indicators) and USDA food access data (6 indicators). Navigate to a county profile to explore its tracts."; |
| 87 | + } else if (q.includes('policy') || q.includes('candidate') || q.includes('hong') || q.includes('mamdani')) { |
| 88 | + response = "The platform tracks 85 policy positions from Francesca Hong (WI Governor candidate, DSA) and Zohran Mamdani (NYC Mayor, DSA). Visit #/candidates to browse and filter."; |
| 89 | + } else if (q.includes('poverty') || q.includes('income') || q.includes('rate')) { |
| 90 | + response = "The average poverty rate across Wisconsin's 72 counties is 10.5% (Census ACS 2023). Menominee County has the highest rate. Browse all counties at #/counties or compare two at #/compare."; |
111 | 91 | } else { |
112 | | - const idx = Math.abs(last.length) % this._placeholders.length; |
113 | | - response = this._placeholders[idx]; |
| 92 | + response = this._placeholders[Math.floor(Math.random() * this._placeholders.length)]; |
114 | 93 | } |
115 | 94 |
|
116 | | - // Simulate streaming at ~50 chars/sec. |
117 | | - for (let i = 0; i < response.length; i += 3) { |
118 | | - onChunk(response.substring(i, Math.min(i + 3, response.length))); |
119 | | - await new Promise(r => setTimeout(r, 20)); |
| 95 | + for (let i = 0; i < response.length; i += 4) { |
| 96 | + onChunk(response.substring(i, Math.min(i + 4, response.length))); |
| 97 | + await new Promise(resolve => setTimeout(resolve, 15)); |
120 | 98 | } |
121 | 99 | onDone(); |
122 | 100 | } |
|
0 commit comments