返回 DeepSeek-Reasonix
attachmentDisplay.ts
根目录 / desktop / frontend / src / lib / attachmentDisplay.ts
1 import { escapeRefPath, refTokenRe, unescapeRefPath } from "./refToken";
2
3 const attachmentRefRe = /@(\.reasonix\/attachments\/[^\s]+)/g;
4 const namedAttachmentRefRe = /(^|\s)@\[([^\]\r\n]+)\]\(([^)\s]+)\)/g;
5 const trailingPunctuationRe = /[.,;!?)\]},。;!?)】]+$/;
6
7 export interface DisplayAttachment {
8 path: string;
9 name: string;
10 kind: "image" | "file" | "folder";
11 source: "attachment" | "workspace";
12 ext: string;
13 }
14
15 function splitTrailingPunctuation(token: string): { core: string; suffix: string } {
16 const m = token.match(trailingPunctuationRe);
17 if (!m || m.index === undefined) return { core: token, suffix: "" };
18 return { core: token.slice(0, m.index), suffix: m[0] };
19 }
20
21 export function baseName(path: string): string {
22 const clean = path.replace(/[\\/]+$/, "");
23 const slash = clean.lastIndexOf("/");
24 const backslash = clean.lastIndexOf("\\");
25 const idx = Math.max(slash, backslash);
26 return idx >= 0 ? clean.slice(idx + 1) : clean;
27 }
28
29 function isImageAttachmentRef(path: string): boolean {
30 const ext = attachmentExt(path);
31 switch (ext) {
32 case ".png":
33 case ".jpg":
34 case ".jpeg":
35 case ".gif":
36 case ".webp":
37 case ".bmp":
38 case ".svg":
39 case ".tif":
40 case ".tiff":
41 return true;
42 default:
43 return false;
44 }
45 }
46
47 function attachmentExt(path: string): string {
48 const name = baseName(path).toLowerCase();
49 const dot = name.lastIndexOf(".");
50 return dot >= 0 ? name.slice(dot) : "";
51 }
52
53 function cleanDisplayName(name: string): string {
54 return name.replace(/\\([\[\]])/g, "$1").replace(/\s+/g, " ").trim();
55 }
56
57 export function replaceAttachmentRefsForDisplay(text: string): string {
58 return text
59 .replace(namedAttachmentRefRe, (_full, lead: string, label: string, token: string) => {
60 const { core, suffix } = splitTrailingPunctuation(token);
61 if (!core || !isDisplayReference(core)) return _full;
62 const name = cleanDisplayName(label) || baseName(core) || "attachment";
63 return `${lead}${isImageAttachmentRef(core) ? "[image]" : `[file:${name}]`}${suffix}`;
64 })
65 .replace(attachmentRefRe, (_full, token: string) => {
66 const { core, suffix } = splitTrailingPunctuation(token);
67 if (!core) return _full;
68 if (isImageAttachmentRef(core)) return `[image]${suffix}`;
69 const name = baseName(core) || "attachment";
70 return `[file:${name}]${suffix}`;
71 });
72 }
73
74 export function restoreAttachmentRefsForSubmit(text: string): string {
75 return text.replace(namedAttachmentRefRe, (_full, lead: string, _label: string, token: string) => {
76 const { core, suffix } = splitTrailingPunctuation(token);
77 if (!core || !isDisplayReference(core)) return _full;
78 return `${lead}@${core}${suffix}`;
79 });
80 }
81
82 function displayRefName(name: string): string {
83 return name.replace(/[\[\]\(\)\r\n]+/g, " ").replace(/\s+/g, " ").trim() || "attachment";
84 }
85
86 export function formatAttachmentRefForDisplay(attachment: Pick<DisplayAttachment, "path" | "name" | "source">): string {
87 if (attachment.source === "attachment") {
88 return `@[${displayRefName(attachment.name || baseName(attachment.path) || "attachment")}](${attachment.path})`;
89 }
90 return `@${escapeRefPath(attachment.path)}`;
91 }
92
93 // Whitespace in the path is escaped so the ref survives @-token parsing on
94 // submit; attachment-store paths are generated without spaces, so this is a
95 // no-op for them.
96 export function formatAttachmentRefForSubmit(attachment: Pick<DisplayAttachment, "path">): string {
97 return `@${escapeRefPath(attachment.path)}`;
98 }
99
100 export function parseAttachmentRefsForDisplay(text: string): { text: string; attachments: DisplayAttachment[] } {
101 const attachments: DisplayAttachment[] = [];
102 const cleaned = text
103 .replace(namedAttachmentRefRe, (_full, lead: string, label: string, token: string) => {
104 const { core, suffix } = splitTrailingPunctuation(token);
105 if (!core || !isDisplayReference(core)) return _full;
106 const name = cleanDisplayName(label) || baseName(core) || "attachment";
107 attachments.push(displayAttachment(core, name));
108 return lead + suffix;
109 })
110 .replace(refTokenRe(), (_full, lead: string, token: string) => {
111 const { core, suffix } = splitTrailingPunctuation(token);
112 const path = unescapeRefPath(core);
113 if (!path || !isDisplayReference(path)) return _full;
114 const name = baseName(path) || "attachment";
115 const attachment = displayAttachment(path, name);
116 attachments.push(attachment);
117 return lead + suffix;
118 })
119 .replace(/[ \t]+([.,;!?)\]},。;!?)】])/g, "$1")
120 .replace(/[ \t]{2,}/g, " ")
121 .trim();
122 return { text: cleaned, attachments };
123 }
124
125 export function sortDisplayAttachments<T extends { kind: "image" | "file" | "folder" }>(attachments: T[]): T[] {
126 return [...attachments].sort((a, b) => {
127 if (a.kind === b.kind) return 0;
128 if (a.kind === "image") return -1;
129 if (b.kind === "image") return 1;
130 return 0;
131 });
132 }
133
134 function isDisplayReference(path: string): boolean {
135 if (path.startsWith(".reasonix/attachments/")) return true;
136 if (path.endsWith("/")) return true;
137 if (path.includes("/")) return true;
138 return attachmentExt(path) !== "";
139 }
140
141 function displayAttachment(path: string, name: string): DisplayAttachment {
142 if (path.startsWith(".reasonix/attachments/")) {
143 const kind = isImageAttachmentRef(path) ? "image" : "file";
144 return {
145 path,
146 name,
147 kind,
148 source: "attachment",
149 ext: attachmentExt(path).replace(/^\./, "").toUpperCase(),
150 };
151 }
152 const isDir = path.endsWith("/");
153 return {
154 path,
155 name,
156 kind: isDir ? "folder" : "file",
157 source: "workspace",
158 ext: isDir ? "" : attachmentExt(path).replace(/^\./, "").toUpperCase(),
159 };
160 }
161
161 lines TYPESCRIPT