diff --git a/.gsd/milestones/M024/slices/S05/S05-PLAN.md b/.gsd/milestones/M024/slices/S05/S05-PLAN.md index ecab934..96ba66a 100644 --- a/.gsd/milestones/M024/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M024/slices/S05/S05-PLAN.md @@ -18,7 +18,7 @@ - Estimate: 30m - Files: backend/search_service.py, backend/chat_service.py - Verify: cd backend && python -c "from chat_service import _build_sources; print('OK')" && python -c "from search_service import SearchService; print('OK')" -- [ ] **T02: Timestamp links and enhanced source cards in ChatPage, ChatWidget, and shared citation utility** — Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility. +- [x] **T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget** — Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility. ## Steps diff --git a/.gsd/milestones/M024/slices/S05/tasks/T01-VERIFY.json b/.gsd/milestones/M024/slices/S05/tasks/T01-VERIFY.json new file mode 100644 index 0000000..30e84eb --- /dev/null +++ b/.gsd/milestones/M024/slices/S05/tasks/T01-VERIFY.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M024/S05/T01", + "timestamp": 1775302859036, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 9, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M024/slices/S05/tasks/T02-SUMMARY.md b/.gsd/milestones/M024/slices/S05/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..f5fe6df --- /dev/null +++ b/.gsd/milestones/M024/slices/S05/tasks/T02-SUMMARY.md @@ -0,0 +1,88 @@ +--- +id: T02 +parent: S05 +milestone: M024 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/utils/chatCitations.tsx", "frontend/src/utils/formatTime.ts", "frontend/src/api/chat.ts", "frontend/src/pages/ChatPage.tsx", "frontend/src/pages/ChatPage.module.css", "frontend/src/components/ChatWidget.tsx", "frontend/src/components/ChatWidget.module.css"] +key_decisions: ["Pass CSS module styles as Record to shared citation parser to avoid CSSModuleClasses structural typing mismatch"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Frontend build passes with zero TypeScript and Vite errors. Confirmed local parse functions and CITATION_RE constants fully removed from both ChatPage.tsx and ChatWidget.tsx." +completed_at: 2026-04-04T11:45:48.138Z +blocker_discovered: false +--- + +# T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget + +> Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget + +## What Happened +--- +id: T02 +parent: S05 +milestone: M024 +key_files: + - frontend/src/utils/chatCitations.tsx + - frontend/src/utils/formatTime.ts + - frontend/src/api/chat.ts + - frontend/src/pages/ChatPage.tsx + - frontend/src/pages/ChatPage.module.css + - frontend/src/components/ChatWidget.tsx + - frontend/src/components/ChatWidget.module.css +key_decisions: + - Pass CSS module styles as Record to shared citation parser to avoid CSSModuleClasses structural typing mismatch +duration: "" +verification_result: passed +completed_at: 2026-04-04T11:45:48.138Z +blocker_discovered: false +--- + +# T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget + +**Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget** + +## What Happened + +Extended ChatSource interface with video fields (source_video_id, start_time, end_time, video_filename). Created shared parseChatCitations utility in utils/chatCitations.tsx, removing duplicate implementations from both ChatPage and ChatWidget. Created shared formatTime utility with hour-aware formatting. Updated source card rendering in both components to show timestamp badges linking to /watch/:id?t=N and video filename metadata. Added corresponding CSS classes to both module stylesheets. + +## Verification + +Frontend build passes with zero TypeScript and Vite errors. Confirmed local parse functions and CITATION_RE constants fully removed from both ChatPage.tsx and ChatWidget.tsx. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 6700ms | +| 2 | `grep -c CITATION_RE ChatPage.tsx ChatWidget.tsx` | 0 | ✅ pass | 100ms | +| 3 | `grep -c 'function parseCitations' ChatPage.tsx ChatWidget.tsx` | 0 | ✅ pass | 100ms | + + +## Deviations + +Used Record for styles param instead of strict interface due to CSSModuleClasses typing. Added .sourceContent wrapper div in ChatWidget for vertical layout. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/utils/chatCitations.tsx` +- `frontend/src/utils/formatTime.ts` +- `frontend/src/api/chat.ts` +- `frontend/src/pages/ChatPage.tsx` +- `frontend/src/pages/ChatPage.module.css` +- `frontend/src/components/ChatWidget.tsx` +- `frontend/src/components/ChatWidget.module.css` + + +## Deviations +Used Record for styles param instead of strict interface due to CSSModuleClasses typing. Added .sourceContent wrapper div in ChatWidget for vertical layout. + +## Known Issues +None. diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index fb0e319..98f656c 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -19,6 +19,10 @@ export interface ChatSource { summary: string; section_anchor: string; section_heading: string; + source_video_id?: string; + start_time?: number; + end_time?: number; + video_filename?: string; } export interface ChatDoneMeta { diff --git a/frontend/src/components/ChatWidget.module.css b/frontend/src/components/ChatWidget.module.css index 5d32aaa..441f785 100644 --- a/frontend/src/components/ChatWidget.module.css +++ b/frontend/src/components/ChatWidget.module.css @@ -462,6 +462,49 @@ cursor: not-allowed; } +/* ── Source card content wrapper ───────────────────────────── */ + +.sourceContent { + display: flex; + flex-direction: column; + gap: 0.125rem; + overflow: hidden; + min-width: 0; +} + +/* ── Timestamp badge & video metadata ─────────────────────── */ + +.timestampBadge { + display: inline-flex; + align-items: center; + gap: 0.1875rem; + padding: 0.0625rem 0.25rem; + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.12); + color: var(--color-accent); + border-radius: 3px; + font-size: 0.625rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-decoration: none; + transition: background 0.15s; + white-space: nowrap; + width: fit-content; +} + +.timestampBadge:hover { + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.22); + text-decoration: none; +} + +.videoMeta { + color: var(--color-text-secondary); + font-size: 0.5625rem; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Responsive (mobile) ──────────────────────────────────── */ @media (max-width: 640px) { diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx index 624c082..088d75d 100644 --- a/frontend/src/components/ChatWidget.tsx +++ b/frontend/src/components/ChatWidget.tsx @@ -9,11 +9,10 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { Link } from "react-router-dom"; import { streamChat, type ChatSource } from "../api/chat"; +import { parseChatCitations } from "../utils/chatCitations"; +import { formatTime } from "../utils/formatTime"; import styles from "./ChatWidget.module.css"; -// Same citation regex as ChatPage / utils/citations.tsx -const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; - interface Technique { title: string; slug: string; @@ -60,61 +59,6 @@ function buildSuggestions(creatorName: string, techniques: Technique[]): string[ return suggestions.slice(0, 3); } -/** Parse citation markers [N] into React nodes with superscript links. */ -function parseCitations(text: string, sources: ChatSource[]): React.ReactNode[] { - const nodes: React.ReactNode[] = []; - let lastIndex = 0; - - for (const match of text.matchAll(CITATION_RE)) { - const matchStart = match.index ?? 0; - if (matchStart > lastIndex) { - nodes.push(text.slice(lastIndex, matchStart)); - } - - const rawGroup = match[1]; - if (!rawGroup) continue; - const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10)); - const links: React.ReactNode[] = []; - - for (let i = 0; i < indices.length; i++) { - const idx = indices[i]!; - const source = sources[idx - 1]; - if (source) { - if (i > 0) links.push(", "); - const href = source.section_anchor - ? `/techniques/${source.slug}#${source.section_anchor}` - : `/techniques/${source.slug}`; - links.push( - - {idx} - , - ); - } else { - if (i > 0) links.push(", "); - links.push(String(idx)); - } - } - - nodes.push( - - [{links}] - , - ); - lastIndex = matchStart + match[0].length; - } - - if (lastIndex < text.length) { - nodes.push(text.slice(lastIndex)); - } - - return nodes.length > 0 ? nodes : [text]; -} - /** Map personality weight to a human-readable tier label. */ function getTierLabel(weight: number): string { if (weight < 0.2) return "Encyclopedic"; @@ -339,7 +283,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
{msg.error}
) : ( <> - {parseCitations(msg.text, msg.sources)} + {parseChatCitations(msg.text, msg.sources, styles)} {!msg.done && } {msg.done && msg.sources.length > 0 && (
@@ -352,10 +296,25 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps) return (
  • {src.number} - - {src.title} - {src.section_heading ? ` — ${src.section_heading}` : ""} - +
    + + {src.title} + {src.section_heading ? ` — ${src.section_heading}` : ""} + + {src.start_time != null && src.source_video_id && ( + + ▶ {formatTime(src.start_time)} + {src.end_time != null && `–${formatTime(src.end_time)}`} + + )} + {src.video_filename && ( + {src.video_filename} + )} +
  • ); })} diff --git a/frontend/src/pages/ChatPage.module.css b/frontend/src/pages/ChatPage.module.css index f56c293..e56bc1b 100644 --- a/frontend/src/pages/ChatPage.module.css +++ b/frontend/src/pages/ChatPage.module.css @@ -252,6 +252,36 @@ font-size: 0.75rem; } +/* ── Timestamp badge & video metadata ─────────────────────── */ + +.timestampBadge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-left: 0.5rem; + padding: 0.125rem 0.375rem; + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.12); + color: var(--color-accent); + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-decoration: none; + transition: background 0.15s; + white-space: nowrap; +} + +.timestampBadge:hover { + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.22); + text-decoration: none; +} + +.videoMeta { + color: var(--color-text-secondary); + font-size: 0.6875rem; + opacity: 0.7; +} + /* ── Citation superscript links in text ───────────────────── */ .citationGroup { diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 674bcd4..4310cdc 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -10,12 +10,11 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; import { Link } from "react-router-dom"; import { streamChat, type ChatSource } from "../api/chat"; +import { parseChatCitations } from "../utils/chatCitations"; +import { formatTime } from "../utils/formatTime"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import styles from "./ChatPage.module.css"; -// Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx -const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; - interface Message { role: "user" | "assistant"; text: string; @@ -24,67 +23,6 @@ interface Message { error?: string; } -/** - * Parse text containing [N] markers into React nodes with citation links. - */ -function parseChatCitations( - text: string, - sources: ChatSource[], -): React.ReactNode[] { - const nodes: React.ReactNode[] = []; - let lastIndex = 0; - - for (const match of text.matchAll(CITATION_RE)) { - const matchStart = match.index ?? 0; - if (matchStart > lastIndex) { - nodes.push(text.slice(lastIndex, matchStart)); - } - - const rawGroup = match[1]; - if (!rawGroup) continue; - const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10)); - const links: React.ReactNode[] = []; - - for (let i = 0; i < indices.length; i++) { - const idx = indices[i]!; - const source = sources[idx - 1]; // 1-based - if (source) { - if (i > 0) links.push(", "); - const href = source.section_anchor - ? `/techniques/${source.slug}#${source.section_anchor}` - : `/techniques/${source.slug}`; - links.push( - - {idx} - , - ); - } else { - if (i > 0) links.push(", "); - links.push(String(idx)); - } - } - - nodes.push( - - [{links}] - , - ); - - lastIndex = matchStart + match[0].length; - } - - if (lastIndex < text.length) { - nodes.push(text.slice(lastIndex)); - } - - return nodes.length > 0 ? nodes : [text]; -} - export default function ChatPage() { useDocumentTitle("Chat — Chrysopedia"); @@ -244,7 +182,7 @@ export default function ChatPage() { ) : ( <>
    - {parseChatCitations(msg.text, msg.sources)} + {parseChatCitations(msg.text, msg.sources, styles)} {!msg.done && }
    {msg.done && msg.sources.length > 0 && ( @@ -273,12 +211,28 @@ export default function ChatPage() { ? ` — ${src.section_heading}` : ""} + {src.start_time != null && src.source_video_id && ( + + ▶ {formatTime(src.start_time)} + {src.end_time != null && `–${formatTime(src.end_time)}`} + + )} {src.creator_name && ( {" "} · {src.creator_name} )} + {src.video_filename && ( + + {" "} + · {src.video_filename} + + )}
    ); diff --git a/frontend/src/utils/chatCitations.tsx b/frontend/src/utils/chatCitations.tsx new file mode 100644 index 0000000..3b6a13b --- /dev/null +++ b/frontend/src/utils/chatCitations.tsx @@ -0,0 +1,77 @@ +/** + * Shared chat citation parser for ChatPage and ChatWidget. + * + * Parses [N] and [N,M] markers in streamed chat text into superscript + * elements pointing to technique pages with optional section anchors. + */ + +import React from "react"; +import { Link } from "react-router-dom"; +import type { ChatSource } from "../api/chat"; + +/** Matches [1], [2,3], [1,2,3] etc. */ +const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; + +/** + * Parse text containing [N] markers into React nodes with citation links. + * Takes a styles object so both ChatPage and ChatWidget can pass their own CSS modules. + * Expects `citationGroup` and `citationLink` keys in the styles object. + */ +export function parseChatCitations( + text: string, + sources: ChatSource[], + styles: Record, +): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + + for (const match of text.matchAll(CITATION_RE)) { + const matchStart = match.index ?? 0; + if (matchStart > lastIndex) { + nodes.push(text.slice(lastIndex, matchStart)); + } + + const rawGroup = match[1]; + if (!rawGroup) continue; + const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10)); + const links: React.ReactNode[] = []; + + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]!; + const source = sources[idx - 1]; // 1-based + if (source) { + if (i > 0) links.push(", "); + const href = source.section_anchor + ? `/techniques/${source.slug}#${source.section_anchor}` + : `/techniques/${source.slug}`; + links.push( + + {idx} + , + ); + } else { + if (i > 0) links.push(", "); + links.push(String(idx)); + } + } + + nodes.push( + + [{links}] + , + ); + + lastIndex = matchStart + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes.length > 0 ? nodes : [text]; +} diff --git a/frontend/src/utils/formatTime.ts b/frontend/src/utils/formatTime.ts new file mode 100644 index 0000000..dcb500c --- /dev/null +++ b/frontend/src/utils/formatTime.ts @@ -0,0 +1,15 @@ +/** + * Format a duration in seconds as M:SS (< 1 hour) or H:MM:SS (>= 1 hour). + * Used by player controls, chapter markers, key moment badges, and chat source cards. + */ +export function formatTime(seconds: number): string { + const total = Math.floor(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + + if (h > 0) { + return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } + return `${m}:${s.toString().padStart(2, "0")}`; +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 2238751..f7690f0 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/templates.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/EmbedPlayer.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/ShortPlayer.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx","./src/utils/clipboard.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/templates.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/EmbedPlayer.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/ShortPlayer.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/chatCitations.tsx","./src/utils/citations.tsx","./src/utils/clipboard.ts","./src/utils/formatTime.ts"],"version":"5.6.3"} \ No newline at end of file