|
212 | 212 | }); |
213 | 213 | }); |
214 | 214 |
|
| 215 | + function escapeHtml(text: string): string { |
| 216 | + return text |
| 217 | + .replace(/&/g, "&") |
| 218 | + .replace(/</g, "<") |
| 219 | + .replace(/>/g, ">") |
| 220 | + .replace(/"/g, """) |
| 221 | + .replace(/'/g, "'"); |
| 222 | + } |
| 223 | + |
| 224 | + function renderRichAssistantContent(container: HTMLElement, rawText: string) { |
| 225 | + // Clean up any localhost or malformed url artifacts |
| 226 | + let cleanedText = rawText |
| 227 | + .replace(/http:\/\/localhost:\d+/g, "") |
| 228 | + .replace(/\[\s*["']?(\/[a-zA-Z0-9_\-\/]+)["']?\s*\]\((?:http:\/\/localhost:\d+)?(\/[a-zA-Z0-9_\-\/]+)\)/g, "[$1]($2)") |
| 229 | + .replace(/\[\s*["']?(\/[a-zA-Z0-9_\-\/]+)["']?\s*\]/g, "[$1]($1)"); |
| 230 | + |
| 231 | + // Extract YouTube links |
| 232 | + const ytRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|live\/))([\w-]{11})/gi; |
| 233 | + const youtubeIds = new Set<string>(); |
| 234 | + let ytMatch: RegExpExecArray | null; |
| 235 | + while ((ytMatch = ytRegex.exec(cleanedText)) !== null) { |
| 236 | + if (ytMatch[1]) { |
| 237 | + youtubeIds.add(ytMatch[1]); |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + // Extract Internal Episode / Post Links |
| 242 | + const internalLinkRegex = /\[([^\]]+)\]\(((\/(?:podcast|post)\/[a-zA-Z0-9_\-]+))\)/gi; |
| 243 | + const internalLinks: Array<{ title: string; url: string; type: "podcast" | "post" }> = []; |
| 244 | + let intMatch: RegExpExecArray | null; |
| 245 | + while ((intMatch = internalLinkRegex.exec(cleanedText)) !== null) { |
| 246 | + const linkUrl = intMatch[2]; |
| 247 | + const linkTitle = intMatch[1]; |
| 248 | + const type = linkUrl.startsWith("/podcast") ? "podcast" : "post"; |
| 249 | + if (!internalLinks.some((l) => l.url === linkUrl)) { |
| 250 | + internalLinks.push({ title: linkTitle, url: linkUrl, type }); |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + // Convert markdown formatting to safe HTML |
| 255 | + let html = escapeHtml(cleanedText); |
| 256 | + |
| 257 | + // Bold: **text** |
| 258 | + html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); |
| 259 | + // Bullet list: - text |
| 260 | + html = html.replace(/^[ \t]*-[ \t]+(.*)$/gm, "<li class='ml-4 list-disc'>$1</li>"); |
| 261 | + // Markdown links: [Title](url) |
| 262 | + html = html.replace( |
| 263 | + /\[([^\]]+)\]\(([^)]+)\)/g, |
| 264 | + "<a href='$2' class='text-primary font-medium hover:underline inline-flex items-center gap-0.5' target='_blank' rel='noopener noreferrer'>$1<svg class='size-3 inline opacity-70' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'><path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'></path><polyline points='15 3 21 3 21 9'></polyline><line x1='10' y1='14' x2='21' y2='3'></line></svg></a>", |
| 265 | + ); |
| 266 | + |
| 267 | + // Naked YouTube URLs |
| 268 | + html = html.replace( |
| 269 | + /(https?:\/\/(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|live\/))[\w-]+[^\s<]*)/gi, |
| 270 | + "<a href='$1' class='text-primary font-medium hover:underline' target='_blank' rel='noopener noreferrer'>$1</a>", |
| 271 | + ); |
| 272 | + |
| 273 | + // Newlines to <br> or <p> |
| 274 | + html = html.replace(/\n\n/g, "<div class='h-2'></div>"); |
| 275 | + html = html.replace(/\n/g, "<br />"); |
| 276 | + |
| 277 | + // Build Cards Section |
| 278 | + let cardsHtml = ""; |
| 279 | + |
| 280 | + // 1. YouTube Preview Cards |
| 281 | + if (youtubeIds.size > 0) { |
| 282 | + cardsHtml += `<div class="mt-3 flex flex-col gap-2.5">`; |
| 283 | + for (const ytId of youtubeIds) { |
| 284 | + const thumbUrl = `https://img.youtube.com/vi/${ytId}/mqdefault.jpg`; |
| 285 | + const ytUrl = `https://www.youtube.com/watch?v=${ytId}`; |
| 286 | + cardsHtml += ` |
| 287 | + <a href="${ytUrl}" target="_blank" rel="noopener noreferrer" class="group relative flex flex-col sm:flex-row items-center gap-3 p-2.5 rounded-xl border border-border bg-card/80 hover:bg-accent/60 transition-all hover:scale-[1.01] hover:shadow-md overflow-hidden text-left no-underline"> |
| 288 | + <div class="relative shrink-0 w-full sm:w-36 h-24 sm:h-20 rounded-lg overflow-hidden bg-black/20 flex items-center justify-center"> |
| 289 | + <img src="${thumbUrl}" alt="YouTube Preview" class="w-full h-full object-cover group-hover:opacity-90 transition-opacity" loading="lazy" /> |
| 290 | + <div class="absolute inset-0 flex items-center justify-center bg-black/25 group-hover:bg-black/10 transition-colors"> |
| 291 | + <div class="size-8 rounded-full bg-red-600/90 text-white flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform"> |
| 292 | + <svg class="size-4 ml-0.5 fill-current" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg> |
| 293 | + </div> |
| 294 | + </div> |
| 295 | + </div> |
| 296 | + <div class="flex-1 min-w-0 py-0.5"> |
| 297 | + <div class="inline-flex items-center gap-1.5 text-[10px] uppercase font-bold tracking-wider text-red-500 mb-0.5"> |
| 298 | + <svg class="size-3.5 fill-current" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg> |
| 299 | + YouTube Video |
| 300 | + </div> |
| 301 | + <div class="text-xs font-semibold text-foreground truncate group-hover:text-primary transition-colors">Watch on YouTube</div> |
| 302 | + <div class="text-[11px] text-muted-foreground truncate">youtube.com/watch?v=${ytId}</div> |
| 303 | + </div> |
| 304 | + </a> |
| 305 | + `; |
| 306 | + } |
| 307 | + cardsHtml += `</div>`; |
| 308 | + } |
| 309 | + |
| 310 | + // 2. Clickable Episode / Post Cards |
| 311 | + if (internalLinks.length > 0) { |
| 312 | + cardsHtml += `<div class="mt-2.5 flex flex-col gap-2">`; |
| 313 | + for (const link of internalLinks) { |
| 314 | + const isPodcast = link.type === "podcast"; |
| 315 | + cardsHtml += ` |
| 316 | + <a href="${link.url}" class="group flex items-center justify-between gap-3 p-3 rounded-xl border border-border bg-card/90 hover:bg-accent hover:border-primary/40 transition-all hover:scale-[1.01] hover:shadow-xs text-left no-underline"> |
| 317 | + <div class="flex items-center gap-2.5 min-w-0"> |
| 318 | + <div class="size-8 rounded-lg ${isPodcast ? "bg-purple-500/10 text-purple-600 dark:text-purple-400" : "bg-blue-500/10 text-blue-600 dark:text-blue-400"} flex items-center justify-center shrink-0"> |
| 319 | + ${ |
| 320 | + isPodcast |
| 321 | + ? '<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" x2="12" y1="19" y2="22"></line></svg>' |
| 322 | + : '<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>' |
| 323 | + } |
| 324 | + </div> |
| 325 | + <div class="min-w-0"> |
| 326 | + <div class="text-xs font-semibold text-foreground group-hover:text-primary transition-colors truncate">${escapeHtml(link.title)}</div> |
| 327 | + <div class="text-[10px] text-muted-foreground uppercase tracking-wider">${isPodcast ? "Podcast Episode" : "Blog Post"} • CodingCat.dev</div> |
| 328 | + </div> |
| 329 | + </div> |
| 330 | + <div class="shrink-0 text-xs font-medium text-primary flex items-center gap-1 group-hover:translate-x-0.5 transition-transform"> |
| 331 | + <span>View</span> |
| 332 | + <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg> |
| 333 | + </div> |
| 334 | + </a> |
| 335 | + `; |
| 336 | + } |
| 337 | + cardsHtml += `</div>`; |
| 338 | + } |
| 339 | + |
| 340 | + container.innerHTML = html + cardsHtml; |
| 341 | + } |
| 342 | + |
215 | 343 | function appendMessage(role: "user" | "assistant", initialText = ""): HTMLElement { |
216 | 344 | if (welcomeContainer) { |
217 | 345 | welcomeContainer.classList.add("hidden"); |
|
232 | 360 | } |
233 | 361 |
|
234 | 362 | const contentDiv = document.createElement("div"); |
235 | | - contentDiv.className = "whitespace-pre-wrap leading-relaxed"; |
| 363 | + contentDiv.className = "leading-relaxed text-sm"; |
236 | 364 | contentDiv.textContent = initialText; |
237 | 365 |
|
238 | 366 | msgDiv.appendChild(contentDiv); |
|
275 | 403 | return; |
276 | 404 | } |
277 | 405 |
|
278 | | - assistantContent.textContent = ""; |
| 406 | + let accumulatedText = ""; |
279 | 407 | const reader = res.body.getReader(); |
280 | 408 | const decoder = new TextDecoder(); |
281 | 409 |
|
282 | 410 | while (true) { |
283 | 411 | const { done, value } = await reader.read(); |
284 | 412 | if (done) break; |
285 | 413 | const chunk = decoder.decode(value, { stream: true }); |
286 | | - assistantContent.textContent += chunk; |
| 414 | + accumulatedText += chunk; |
| 415 | + // Render progressive text during streaming |
| 416 | + assistantContent.textContent = accumulatedText; |
287 | 417 | messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: "smooth" }); |
288 | 418 | } |
| 419 | + |
| 420 | + // On complete stream finish: render full rich content with cards & links |
| 421 | + renderRichAssistantContent(assistantContent, accumulatedText); |
| 422 | + messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: "smooth" }); |
289 | 423 | } catch (err) { |
290 | 424 | assistantContent.textContent = `Connection error: ${err instanceof Error ? err.message : String(err)}`; |
291 | 425 | } finally { |
|
0 commit comments