返回 DeepSeek-Reasonix
chatFileCandidates.ts
根目录 / desktop / frontend / src / lib / chatFileCandidates.ts
1 // Candidates the answer body names but never declared with `present`.
2 //
3 // Extraction runs on the parsed blocks — inline code and local-path links —
4 // never on raw source, so a command, a regular expression, or the inside of a
5 // fenced block cannot become a file reference. Nothing here decides whether a
6 // file exists: every candidate is verified by the host before it becomes
7 // clickable, and a candidate that fails verification keeps its original text.
8
9 import type { Element, RootContent } from "hast";
10 import { fileIdentity, isAbsolutePath, pathBasename, pathExtension } from "./filePaths";
11 import { localPathFromHref } from "./localFileUrl";
12 import { unescapeRefPath } from "./refToken";
13
14 export interface ChatFileCandidate {
15 /** Renderer identity; also the key the host echoes back. */
16 key: string;
17 /** The path exactly as the answer spelled it. */
18 path: string;
19 }
20
21 /** A code span is only worth verifying when it carries a directory of its own. */
22 const DIRECTORY_SEPARATOR_RE = /[\\/]/;
23
24 function isCodeSpanCandidate(text: string): boolean {
25 const value = unescapeRefPath(text.trim());
26 if (!value || value.length > 4096 || value.includes("\n")) return false;
27 if (isAbsolutePath(value)) return true;
28 // A bare filename matches the turn's known file set instead, so it is never
29 // sent to the host: resolving it would guess at a file the answer never named.
30 return DIRECTORY_SEPARATOR_RE.test(value) && pathBasename(value) !== "";
31 }
32
33 /**
34 * An explicit Markdown link may name a local file directly (`[x](/tmp/a.pdf)`)
35 * as well as through a `file://` URL. Only an absolute path with a file
36 * extension qualifies: a bare `/docs/getting-started` style site path must not
37 * become a file reference.
38 */
39 function hrefLocalPath(href: unknown): string | undefined {
40 if (typeof href !== "string") return undefined;
41 const decoded = localPathFromHref(href);
42 if (decoded) return decoded;
43 const trimmed = href.trim();
44 if (!isAbsolutePath(trimmed) || !pathExtension(trimmed)) return undefined;
45 return trimmed;
46 }
47
48 /**
49 * Walks one parsed block. `code` elements are inline spans only — a fenced
50 * block arrives as a `<pre><code>` pair and is skipped whole.
51 */
52 function collectFromNode(node: RootContent, out: ChatFileCandidate[], seen: Set<string>): void {
53 if (node.type === "element") {
54 const element = node as Element;
55 const tag = element.tagName.toLowerCase();
56 if (tag === "pre") return;
57 if (tag === "code") {
58 const text = textOf(element).trim();
59 if (text && isCodeSpanCandidate(text)) push(out, seen, unescapeRefPath(text));
60 return;
61 }
62 if (tag === "a") {
63 const decoded = hrefLocalPath(element.properties?.href);
64 if (decoded) push(out, seen, decoded);
65 // A link's own text is not scanned: it is a label, not a path.
66 return;
67 }
68 }
69 if ("children" in node && Array.isArray(node.children)) {
70 for (const child of node.children) collectFromNode(child as RootContent, out, seen);
71 }
72 }
73
74 function push(out: ChatFileCandidate[], seen: Set<string>, raw: string): void {
75 const path = raw.trim();
76 if (!path || path.length > 4096) return;
77 const identity = fileIdentity(path);
78 if (!identity || seen.has(identity)) return;
79 seen.add(identity);
80 out.push({ key: identity, path });
81 }
82
83 function textOf(element: Element): string {
84 let text = "";
85 for (const child of element.children ?? []) {
86 if (child.type === "text") text += child.value;
87 else if (child.type === "element") text += textOf(child as Element);
88 }
89 return text;
90 }
91
92 /** Deduplicated candidates for one parsed block list, in document order. */
93 export function chatFileCandidates(blocks: readonly { children: RootContent[] }[]): ChatFileCandidate[] {
94 const out: ChatFileCandidate[] = [];
95 const seen = new Set<string>();
96 for (const block of blocks) {
97 for (const child of block.children) collectFromNode(child, out, seen);
98 }
99 return out;
100 }
101
101 lines TYPESCRIPT