| 1 | export type SearchSource = { |
| 2 | title?: string; |
| 3 | url?: string; |
| 4 | }; |
| 5 | |
| 6 | export interface HistoryServerSearch { |
| 7 | id: string; |
| 8 | query?: string; |
| 9 | sources_status?: "available" | "not_provided"; |
| 10 | results?: { title?: string; url?: string }[]; |
| 11 | } |
| 12 | |
| 13 | export function isSafeHttpUrl(url: string): boolean { |
| 14 | try { |
| 15 | const parsed = new URL(url); |
| 16 | return parsed.protocol === "http:" || parsed.protocol === "https:"; |
| 17 | } catch { |
| 18 | return false; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | function sourceKey(source: SearchSource): string { |
| 23 | return `${source.url ?? ""}\n${source.title ?? ""}`; |
| 24 | } |
| 25 | |
| 26 | export function mergeSearchSources(dst: SearchSource[] | undefined, add: SearchSource[]): SearchSource[] { |
| 27 | const out = dst ? dst.slice() : []; |
| 28 | const seen = new Set(out.map(sourceKey)); |
| 29 | for (const hit of add) { |
| 30 | if (!hit.title && !hit.url) continue; |
| 31 | const key = sourceKey(hit); |
| 32 | if (seen.has(key)) continue; |
| 33 | seen.add(key); |
| 34 | out.push({ title: hit.title, url: hit.url }); |
| 35 | } |
| 36 | return out; |
| 37 | } |
| 38 | |
| 39 | export function searchSourcesFromHistory(searches: { results?: { title?: string; url?: string }[] }[] | undefined): SearchSource[] { |
| 40 | const add: SearchSource[] = []; |
| 41 | for (const search of searches ?? []) { |
| 42 | for (const hit of search.results ?? []) { |
| 43 | if (hit.title || hit.url) add.push({ title: hit.title, url: hit.url }); |
| 44 | } |
| 45 | } |
| 46 | return mergeSearchSources(undefined, add); |
| 47 | } |
| 48 | |
| 49 | export function parseSearchSources(output: string): SearchSource[] { |
| 50 | if (output.trimStart().startsWith("{")) { |
| 51 | try { |
| 52 | const result: unknown = JSON.parse(output); |
| 53 | if (result && typeof result === "object" && "sources" in result && Array.isArray(result.sources)) { |
| 54 | return result.sources.flatMap((source: unknown): SearchSource[] => { |
| 55 | if (!source || typeof source !== "object" || !("url" in source) || typeof source.url !== "string" || !isSafeHttpUrl(source.url)) return []; |
| 56 | return [{ url: source.url, title: "title" in source && typeof source.title === "string" ? source.title : undefined }]; |
| 57 | }); |
| 58 | } |
| 59 | } catch { |
| 60 | // Old search output is plain text; preserve its existing parser. |
| 61 | } |
| 62 | } |
| 63 | const lines = output.split("\n").map((line) => line.trim()).filter(Boolean); |
| 64 | const out: SearchSource[] = []; |
| 65 | for (const line of lines) { |
| 66 | // Tolerate the footnote-markdown shape (`- **title**` / `<url>`) as well: |
| 67 | // if a degraded plain-text dump ever reaches this parser (#8900), sources |
| 68 | // still resolve into cards/footnotes instead of leaking raw markup. |
| 69 | const urlMatch = /^<?(https?:\/\/[^>\s]+)>?$/i.exec(line); |
| 70 | if (urlMatch) { |
| 71 | const last = out[out.length - 1]; |
| 72 | if (last && !last.url) last.url = urlMatch[1]; |
| 73 | else out.push({ url: urlMatch[1] }); |
| 74 | continue; |
| 75 | } |
| 76 | const titleMatch = /^[-*]\s+\*\*(.+)\*\*$/.exec(line); |
| 77 | out.push({ title: titleMatch?.[1] ?? line }); |
| 78 | } |
| 79 | return out; |
| 80 | } |
| 81 | |
| 82 | /** Same title + autolink list the old answer dump used, rendered after the reply. */ |
| 83 | export function formatSearchFootnotesMarkdown(sources: SearchSource[]): string { |
| 84 | const lines: string[] = []; |
| 85 | for (const source of sources) { |
| 86 | if (!source.title && !source.url) continue; |
| 87 | lines.push(`- **${source.title ?? ""}**`); |
| 88 | if (source.url && isSafeHttpUrl(source.url)) lines.push(` <${source.url}>`); |
| 89 | } |
| 90 | return lines.length > 0 ? `\n${lines.join("\n")}\n` : ""; |
| 91 | } |
| 92 | |
| 93 | // Only explicit structured metadata carries availability; never infer it from prose. |
| 94 | export function searchOutputMetadata(output?: string): { status?: "available" | "not_provided"; summary?: string } { |
| 95 | if (!output) return {}; |
| 96 | try { |
| 97 | const value = JSON.parse(output); |
| 98 | if (!value || typeof value !== "object") return {}; |
| 99 | return { status: value.sources_status === "available" || value.sources_status === "not_provided" ? value.sources_status : undefined, |
| 100 | summary: typeof value.summary === "string" ? value.summary : undefined }; |
| 101 | } catch { return {}; } |
| 102 | } |
| 103 |