Skip to content

Commit 4f2ed71

Browse files
committed
enhance(chat): show per-type locators on source cards
Documents lead with the page number, web links with a cleaned display URL, and videos with a 12:34-style position parsed from a clock-valued labeled_page_number or a t/start/#t= URL parameter (doc_query has no timestamp field yet). Card titles clamp at two lines instead of truncating so long file names stay identifiable.
1 parent 9cb0cc9 commit 4f2ed71

5 files changed

Lines changed: 382 additions & 13 deletions

File tree

apps/chat/src/components/sources-section.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,16 @@ function SourceCard({
6565
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
6666
/>
6767
<span className="min-w-0 flex-1">
68-
<span className="text-foreground block truncate text-sm font-medium">
68+
{/* Two lines, not `truncate`: these are file names like
69+
`kapitel-4-erwartungswert-und-varianz.pdf`, which a single
70+
ellipsized line cuts before the part that identifies it. `title`
71+
keeps the untruncated name reachable on hover. No `block` here —
72+
it would override the `display: -webkit-box` that `line-clamp-2`
73+
needs, silently disabling the clamp. */}
74+
<span
75+
title={source.title}
76+
className="text-foreground line-clamp-2 break-words text-sm font-medium"
77+
>
6978
{source.title}
7079
</span>
7180
{secondaryLine && (

apps/chat/src/lib/sources/sourceDisplay.ts

Lines changed: 165 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import type { useTranslations } from 'next-intl'
22

33
import type { ChatSource, ChatSourceType } from './types'
44

5-
// Video/image sources have no page/chapter data — just a type label — while
6-
// documents and plain links show page/chapter info. Shared by the sources
7-
// grid (sources-section.tsx) and the inline citation hover preview
5+
// Video/image sources get their own compact grid in `sources-section.tsx`.
6+
// Shared by the sources grid and the inline citation hover preview
87
// (citation-chip.tsx) so both agree on what a source "is".
98
export const MEDIA_SOURCE_TYPES: readonly ChatSourceType[] = ['video', 'image']
109

@@ -18,27 +17,181 @@ export function isMediaSource(source: Pick<ChatSource, 'type'>): boolean {
1817
// excessively deep" against the full Messages union.
1918
export type Translate = ReturnType<typeof useTranslations<never>>
2019

20+
const MAX_DISPLAY_URL_LENGTH = 48
21+
22+
/** `754` -> `12:34`, `3723` -> `1:02:03`. Minutes stay unpadded. */
23+
export function formatTimestamp(totalSeconds: number): string {
24+
const safe = Math.max(0, Math.floor(totalSeconds))
25+
const hours = Math.floor(safe / 3600)
26+
const minutes = Math.floor((safe % 3600) / 60)
27+
const seconds = safe % 60
28+
const paddedSeconds = String(seconds).padStart(2, '0')
29+
30+
if (hours > 0) {
31+
return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`
32+
}
33+
return `${minutes}:${paddedSeconds}`
34+
}
35+
36+
/**
37+
* Seconds from the time notations a course video link or a chunk label
38+
* realistically uses: plain seconds (`90`), clock form (`1:30`, `1:02:03`),
39+
* and YouTube's compound form (`1m30s`, `1h2m3s`). Returns `undefined` for
40+
* anything else, so a non-time `labeled_page_number` such as `"Kapitel IV"`
41+
* is never mistaken for a timestamp.
42+
*/
43+
export function parseTimestampSeconds(value: string): number | undefined {
44+
const raw = value.trim().toLowerCase()
45+
if (!raw) return undefined
46+
47+
if (/^\d+$/.test(raw)) return Number(raw)
48+
if (/^\d+s$/.test(raw)) return Number(raw.slice(0, -1))
49+
50+
const clock = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(raw)
51+
if (clock) {
52+
const [, hours, minutes, seconds] = clock
53+
if (Number(minutes) > 59 || Number(seconds) > 59) return undefined
54+
return Number(hours ?? 0) * 3600 + Number(minutes) * 60 + Number(seconds)
55+
}
56+
57+
const compound = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(raw)
58+
if (compound && (compound[1] || compound[2] || compound[3])) {
59+
const [, hours, minutes, seconds] = compound
60+
return (
61+
Number(hours ?? 0) * 3600 +
62+
Number(minutes ?? 0) * 60 +
63+
Number(seconds ?? 0)
64+
)
65+
}
66+
67+
return undefined
68+
}
69+
70+
/**
71+
* A video position for the card and the hover preview.
72+
*
73+
* doc_query has no timestamp field (its source shape is `source_url`,
74+
* `source_type`, `file_name`, `page_number`, `labeled_page_number`), so this
75+
* reads the two channels a timestamp can actually arrive in today: a free-form
76+
* `labeled_page_number` the ingestion side may set to a clock value, and the
77+
* time parameter a course video URL usually carries. A dedicated field is
78+
* phase-2 work in the doc-query service; until then a video without either
79+
* simply shows its type label.
80+
*/
81+
export function getSourceTimestamp(source: ChatSource): string | undefined {
82+
if (source.labeledPage) {
83+
const labeled = parseTimestampSeconds(source.labeledPage)
84+
if (labeled !== undefined) return formatTimestamp(labeled)
85+
}
86+
87+
if (!source.url) return undefined
88+
89+
try {
90+
const parsed = new URL(source.url)
91+
const candidates = [
92+
parsed.searchParams.get('t'),
93+
parsed.searchParams.get('start'),
94+
parsed.searchParams.get('time_continue'),
95+
// `#t=90` / `#t=1m30s`
96+
/^#t=(.+)$/.exec(parsed.hash)?.[1],
97+
]
98+
99+
for (const candidate of candidates) {
100+
if (!candidate) continue
101+
const seconds = parseTimestampSeconds(candidate)
102+
if (seconds !== undefined) return formatTimestamp(seconds)
103+
}
104+
} catch {
105+
return undefined
106+
}
107+
108+
return undefined
109+
}
110+
111+
/**
112+
* The readable form of a web source's address: no scheme, no `www.`, no
113+
* trailing slash, truncated in the middle of the path rather than at the end
114+
* so the host always stays visible.
115+
*/
116+
export function getDisplayUrl(url: string): string | undefined {
117+
let host: string
118+
let rest: string
119+
120+
try {
121+
const parsed = new URL(url)
122+
host = parsed.host.replace(/^www\./i, '')
123+
rest = `${parsed.pathname}${parsed.search}${parsed.hash}`.replace(/\/$/, '')
124+
} catch {
125+
return undefined
126+
}
127+
128+
if (!host) return undefined
129+
if (rest === '/' || rest === '') return host
130+
131+
const full = `${host}${rest}`
132+
if (full.length <= MAX_DISPLAY_URL_LENGTH) return full
133+
134+
const keep = Math.max(0, MAX_DISPLAY_URL_LENGTH - host.length - 1)
135+
return `${host}${rest.slice(rest.length - keep)}`
136+
}
137+
21138
/**
22-
* "S. 4 · IV" when both a numeric page and a human page label are present,
23-
* either one alone, a type label for video/image, or `null` when none of
24-
* these apply. Shared so the source card and the citation preview render an
25-
* identical secondary line for the same source.
139+
* The locator line under a source's name, by what that kind of source is
140+
* actually addressed by: a page for documents, a position for videos, an
141+
* address for web links. Falls back to the address when a document carries no
142+
* page at all, so the line stays informative instead of empty. `null` only
143+
* when nothing at all is known.
144+
*
145+
* Shared so the source card and the citation preview render an identical
146+
* secondary line for the same source.
26147
*/
27148
export function getSourceSecondaryLine(
28149
source: ChatSource,
29150
t: Translate
30151
): string | null {
31152
const parts: string[] = []
32153

33-
if (isMediaSource(source)) {
34-
parts.push(
35-
t(source.type === 'video' ? 'chat.sources.video' : 'chat.sources.image')
36-
)
154+
if (source.type === 'video') {
155+
const timestamp = getSourceTimestamp(source)
156+
parts.push(timestamp ?? t('chat.sources.video'))
157+
if (timestamp === undefined && typeof source.page === 'number') {
158+
parts.push(t('chat.sources.page', { page: source.page }))
159+
}
160+
return parts.join(' · ')
37161
}
162+
163+
if (source.type === 'image') {
164+
parts.push(t('chat.sources.image'))
165+
if (typeof source.page === 'number') {
166+
parts.push(t('chat.sources.page', { page: source.page }))
167+
}
168+
return parts.join(' · ')
169+
}
170+
171+
// A web link is addressed by its URL, so that leads here even when the
172+
// payload also carried a page. Documents lead with the page and only fall
173+
// back to the URL when they have no page at all, which keeps the common
174+
// "lecture-01.pdf / p. 12" pairing intact.
175+
const displayUrl = source.url ? getDisplayUrl(source.url) : undefined
176+
177+
if (source.type === 'link' && displayUrl) {
178+
return displayUrl
179+
}
180+
38181
if (typeof source.page === 'number') {
39182
parts.push(t('chat.sources.page', { page: source.page }))
40183
}
41-
if (source.labeledPage) parts.push(source.labeledPage)
184+
// A labeled page ("IV", "A-3") is the publisher's own numbering and only
185+
// adds something next to the numeric page; a labeled timestamp already went
186+
// to the video branch above.
187+
if (
188+
source.labeledPage &&
189+
parseTimestampSeconds(source.labeledPage) === undefined
190+
) {
191+
parts.push(source.labeledPage)
192+
}
193+
194+
if (parts.length === 0 && displayUrl) parts.push(displayUrl)
42195

43196
return parts.length > 0 ? parts.join(' · ') : null
44197
}

0 commit comments

Comments
 (0)