Skip to content

Commit 44a225d

Browse files
committed
refactor(agent-streaming): Convert client to TS
- refactor: Convert frontend to TypeScript - refactor: Update index.html to reference TS - chore: Add typecheck to build script
1 parent a23af3a commit 44a225d

3 files changed

Lines changed: 255 additions & 2 deletions

File tree

agent-streaming/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
</form>
3434
</div>
3535

36-
<script type="module" src="/src/client/script.js"></script>
36+
<script type="module" src="/src/client/script.ts"></script>
3737
</body>
3838

3939
</html>

agent-streaming/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"test": "echo \"Error: no test specified\" && exit 1",
88
"dev:client": "vite",
99
"dev:server": "tsx --env-file=.env --watch src/server/index.ts",
10-
"build": "vite build",
10+
"build": "tsc --noEmit && vite build",
1111
"start": "tsx --env-file=.env src/server/index.ts"
1212
},
1313
"keywords": [],
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { marked } from 'marked';
2+
import DOMPurify from 'dompurify';
3+
4+
interface StreamChunk {
5+
type: 'thought' | 'text' | 'error';
6+
content: string;
7+
currentStep?: string;
8+
}
9+
10+
document.addEventListener('DOMContentLoaded', () => {
11+
const chatForm = document.getElementById('chat-form') as HTMLFormElement;
12+
const promptInput = document.getElementById('prompt-input') as HTMLInputElement;
13+
const chatMessages = document.getElementById('chat-messages') as HTMLDivElement;
14+
const sendButton = document.getElementById('send-button') as HTMLButtonElement;
15+
16+
chatForm.addEventListener('submit', async (e: Event) => {
17+
e.preventDefault();
18+
const prompt = promptInput.value.trim();
19+
if (!prompt) return;
20+
21+
// Clear input
22+
promptInput.value = '';
23+
promptInput.disabled = true;
24+
sendButton.disabled = true;
25+
26+
// Append User Message Safely
27+
appendUserMessage(prompt);
28+
29+
// Setup streaming state for unified thinking card
30+
let activeThoughtElement: HTMLDivElement | null = null;
31+
let activeThoughtStepLabel: HTMLDivElement | null = null;
32+
let activeThoughtIndicator: HTMLDivElement | null = null;
33+
let activeThoughtBody: HTMLDivElement | null = null;
34+
let activeStepName: string | null = null;
35+
36+
let activeTextMessageElement: HTMLDivElement | null = null;
37+
let activeTextMessageContent: HTMLDivElement | null = null;
38+
let accumulatedModelText: string = '';
39+
40+
try {
41+
const response = await fetch('/api/chat', {
42+
method: 'POST',
43+
headers: {
44+
'Content-Type': 'application/json',
45+
},
46+
body: JSON.stringify({ prompt }),
47+
});
48+
49+
if (!response.ok) {
50+
throw new Error('Network response was not ok');
51+
}
52+
if (!response.body) {
53+
throw new Error('Response body is null');
54+
}
55+
const reader = response.body.getReader();
56+
57+
const decoder = new TextDecoder();
58+
let buffer = '';
59+
60+
while (true) {
61+
const { value, done } = await reader.read();
62+
if (done) break;
63+
64+
buffer += decoder.decode(value, { stream: true });
65+
const lines = buffer.split('\n');
66+
67+
// Keep the last partial line in the buffer
68+
buffer = lines.pop() || '';
69+
70+
for (const line of lines) {
71+
const trimmed = line.trim();
72+
if (trimmed.startsWith('data: ')) {
73+
const jsonStr = trimmed.slice(6);
74+
try {
75+
const chunk = JSON.parse(jsonStr) as StreamChunk;
76+
handleStreamChunk(chunk);
77+
} catch (err) {
78+
console.error('Failed to parse SSE JSON:', err);
79+
}
80+
}
81+
}
82+
}
83+
} catch (error) {
84+
console.error('Streaming error:', error);
85+
appendErrorMessage('Failed to connect to the agent. Please try again.');
86+
} finally {
87+
promptInput.disabled = false;
88+
sendButton.disabled = false;
89+
promptInput.focus();
90+
}
91+
92+
// Handle each chunk type safely using standard elements & textContent
93+
function handleStreamChunk(chunk: StreamChunk) {
94+
if (chunk.type === 'thought') {
95+
const stepName = chunk.currentStep || 'Thinking';
96+
const content = chunk.content || '';
97+
98+
// If we don't have an active thought card yet, create one
99+
if (!activeThoughtElement) {
100+
activeStepName = stepName;
101+
createThoughtContainer(stepName);
102+
} else if (stepName !== activeStepName) {
103+
// Update existing thought card for NEW THINKING animation
104+
activeStepName = stepName;
105+
if (activeThoughtStepLabel && activeThoughtIndicator) {
106+
activeThoughtStepLabel.textContent = `Thinking: ${stepName}`;
107+
108+
// Re-trigger new thinking animation
109+
activeThoughtStepLabel.classList.remove('step-animate');
110+
activeThoughtIndicator.classList.remove('indicator-animate');
111+
void activeThoughtStepLabel.offsetWidth; // trigger reflow
112+
activeThoughtStepLabel.classList.add('step-animate');
113+
activeThoughtIndicator.classList.add('indicator-animate');
114+
}
115+
}
116+
117+
// Append content to the thought body safely
118+
if (activeThoughtBody) {
119+
activeThoughtBody.textContent += content;
120+
}
121+
} else if (chunk.type === 'text') {
122+
const content = chunk.content || '';
123+
accumulatedModelText += content;
124+
125+
// If we don't have an active text bubble yet, create one
126+
if (!activeTextMessageElement) {
127+
if (activeThoughtBody) {
128+
activeThoughtBody.textContent = activeThoughtBody.textContent.trim();
129+
}
130+
createTextBubble();
131+
}
132+
133+
// STRICT SECURE PIPELINE: 1. Parse MD -> 2. Sanitize HTML -> 3. Render via innerHTML
134+
if (activeTextMessageContent && typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
135+
try {
136+
// @ts-ignore: marked typing compatibility
137+
const parseMd = typeof marked.parse === 'function' ? marked.parse : marked;
138+
const rawHtml = parseMd(accumulatedModelText, { breaks: true }) as string;
139+
const safeHtml = DOMPurify.sanitize(rawHtml);
140+
activeTextMessageContent.innerHTML = safeHtml;
141+
} catch (err) {
142+
console.error('Markdown runtime parsing error:', err);
143+
activeTextMessageContent.textContent = accumulatedModelText;
144+
}
145+
} else if (activeTextMessageContent) {
146+
activeTextMessageContent.textContent = accumulatedModelText;
147+
}
148+
} else if (chunk.type === 'error') {
149+
appendErrorMessage(chunk.content);
150+
}
151+
152+
// Auto scroll to bottom
153+
chatMessages.scrollTop = chatMessages.scrollHeight;
154+
}
155+
156+
// Helper functions to safely manipulate DOM to prevent XSS
157+
function createThoughtContainer(stepName: string) {
158+
const messageDiv = document.createElement('div');
159+
messageDiv.className = 'message thought-message';
160+
161+
const contentDiv = document.createElement('div');
162+
contentDiv.className = 'thought-box';
163+
164+
const headerDiv = document.createElement('div');
165+
headerDiv.className = 'thought-header';
166+
167+
const indicatorDiv = document.createElement('div');
168+
indicatorDiv.className = 'thought-indicator indicator-animate';
169+
170+
const stepLabelDiv = document.createElement('div');
171+
stepLabelDiv.className = 'thought-step-label step-animate';
172+
stepLabelDiv.textContent = `Thinking: ${stepName}`;
173+
174+
headerDiv.appendChild(indicatorDiv);
175+
headerDiv.appendChild(stepLabelDiv);
176+
177+
const details = document.createElement('details');
178+
details.className = 'thought-details';
179+
180+
const summary = document.createElement('summary');
181+
summary.className = 'thought-summary';
182+
183+
const summaryText = document.createElement('span');
184+
summaryText.className = 'summary-text';
185+
summaryText.textContent = 'Show Full Reasoning';
186+
187+
summary.appendChild(summaryText);
188+
189+
const body = document.createElement('div');
190+
body.className = 'thought-body';
191+
body.textContent = ''; // starts empty
192+
193+
details.appendChild(summary);
194+
details.appendChild(body);
195+
196+
details.addEventListener('toggle', () => {
197+
summaryText.textContent = details.open ? 'Hide Full Reasoning' : 'Show Full Reasoning';
198+
});
199+
200+
contentDiv.appendChild(headerDiv);
201+
contentDiv.appendChild(details);
202+
messageDiv.appendChild(contentDiv);
203+
chatMessages.appendChild(messageDiv);
204+
205+
activeThoughtElement = messageDiv;
206+
activeThoughtStepLabel = stepLabelDiv;
207+
activeThoughtIndicator = indicatorDiv;
208+
activeThoughtBody = body;
209+
}
210+
211+
function createTextBubble() {
212+
const messageDiv = document.createElement('div');
213+
messageDiv.className = 'message model-message';
214+
215+
const contentDiv = document.createElement('div');
216+
contentDiv.className = 'message-content';
217+
contentDiv.textContent = ''; // starts empty
218+
219+
messageDiv.appendChild(contentDiv);
220+
chatMessages.appendChild(messageDiv);
221+
222+
activeTextMessageElement = messageDiv;
223+
activeTextMessageContent = contentDiv;
224+
}
225+
226+
});
227+
228+
function appendUserMessage(text: string) {
229+
const messageDiv = document.createElement('div');
230+
messageDiv.className = 'message user-message';
231+
232+
const contentDiv = document.createElement('div');
233+
contentDiv.className = 'message-content';
234+
contentDiv.textContent = text; // Safe text assignment
235+
236+
messageDiv.appendChild(contentDiv);
237+
chatMessages.appendChild(messageDiv);
238+
chatMessages.scrollTop = chatMessages.scrollHeight;
239+
}
240+
241+
function appendErrorMessage(text: string) {
242+
const messageDiv = document.createElement('div');
243+
messageDiv.className = 'message system-message error';
244+
245+
const contentDiv = document.createElement('div');
246+
contentDiv.className = 'message-content';
247+
contentDiv.textContent = text;
248+
249+
messageDiv.appendChild(contentDiv);
250+
chatMessages.appendChild(messageDiv);
251+
chatMessages.scrollTop = chatMessages.scrollHeight;
252+
}
253+
});

0 commit comments

Comments
 (0)