返回 DeepSeek-Reasonix
searchSources.ts
根目录 / desktop / frontend / src / lib / searchSources.ts
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
103 lines TYPESCRIPT