返回 DeepSeek-Reasonix
invocationDisplay.ts
根目录 / desktop / frontend / src / lib / invocationDisplay.ts
1 import type { CommandInfo } from "./types";
2
3 export type InvocationKind = "skill" | "subagent";
4 export type InvocationMetadata = { kind: InvocationKind; color?: string };
5 export type InvocationMetadataMap = Readonly<Record<string, InvocationMetadata>>;
6
7 export type InvocationDisplay = {
8 name: string;
9 label: string;
10 source?: string;
11 kind?: InvocationKind;
12 color?: string;
13 };
14
15 export type ComposerInvocation = {
16 id: string;
17 offset: number;
18 command: CommandInfo;
19 };
20
21 export type InvocationRequest = {
22 name: string;
23 kind: InvocationKind;
24 offset: number;
25 };
26
27 export type StructuredInvocationSubmit = {
28 modelApplicationChoice?: import("./modelApplication").ModelApplicationChoice;
29 display: string;
30 input: string;
31 invocations: InvocationRequest[];
32 attachmentTarget?: string;
33 attachmentSubmissionId?: string;
34 attachments?: SubmissionAttachment[];
35 };
36
37 export type SubmissionAttachment = {
38 clientAttachmentId: string;
39 draftId?: string;
40 path?: string;
41 };
42
43 export function invocationRequests(invocations: ComposerInvocation[]): InvocationRequest[] {
44 return sortComposerInvocations(invocations).map((invocation) => ({
45 name: invocation.command.name,
46 kind: invocation.command.kind === "subagent" ? "subagent" : "skill",
47 offset: invocation.offset,
48 }));
49 }
50
51 // A user can paste a complete slash invocation without selecting the rich
52 // composer token. Goal setup needs the same structured path for that input so
53 // the slash name is not absorbed into the goal text.
54 export function typedStructuredInvocationDraft(
55 text: string,
56 commands: CommandInfo[],
57 ): { text: string; invocations: ComposerInvocation[] } | null {
58 const match = /^\/([A-Za-z0-9_.:-]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
59 if (!match) return null;
60 const command = commands.find((candidate) => candidate.name === match[1] && commandUsesStructuredInvocation(candidate));
61 if (!command) return null;
62 return {
63 text: (match[2] ?? "").trim(),
64 invocations: [{ id: `typed-invocation-${command.name}`, offset: 0, command }],
65 };
66 }
67
68 export type InvocationTextSegment =
69 | { type: "text"; content: string; start: number }
70 | { type: "invocation"; invocation: InvocationDisplay; offset: number };
71
72 const invocationNamePattern = "[A-Za-z0-9_.:-]+";
73 const knownSubagents = new Set(["general-purpose", "explore", "research", "review", "security_review"]);
74
75 export function commandUsesStructuredInvocation(command: CommandInfo): boolean {
76 return command.kind === "skill" || command.kind === "subagent";
77 }
78
79 export function commandAvailableAtSlashPosition(command: CommandInfo, atMessageStart: boolean): boolean {
80 return atMessageStart || commandUsesStructuredInvocation(command);
81 }
82
83 export function invocationLabel(name: string): string {
84 const unqualified = name.split(":").pop() || name;
85 return unqualified
86 .split(/[-_.]+/)
87 .filter(Boolean)
88 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
89 .join(" ");
90 }
91
92 export function invocationDisplayForCommand(command: CommandInfo): InvocationDisplay {
93 return {
94 name: command.name,
95 label: invocationLabel(command.name),
96 source: command.plugin || command.name.split(":").slice(0, -1).join(":") || undefined,
97 kind: command.kind === "subagent" ? "subagent" : "skill",
98 color: command.color,
99 };
100 }
101
102 export function sortComposerInvocations(invocations: ComposerInvocation[]): ComposerInvocation[] {
103 return invocations
104 .map((invocation, index) => ({ invocation, index }))
105 .sort((a, b) => a.invocation.offset - b.invocation.offset || a.index - b.index)
106 .map(({ invocation }) => invocation);
107 }
108
109 export function replaceInvocationTextRange(
110 text: string,
111 invocations: ComposerInvocation[],
112 start: number,
113 end: number,
114 value: string,
115 afterInvocationId?: string,
116 ): { text: string; invocations: ComposerInvocation[] } {
117 const from = Math.max(0, Math.min(start, end, text.length));
118 const to = Math.max(from, Math.min(Math.max(start, end), text.length));
119 const delta = value.length - (to - from);
120 const ordered = sortComposerInvocations(invocations);
121 const anchorIndex = from === to && afterInvocationId
122 ? ordered.findIndex((invocation) => invocation.id === afterInvocationId && invocation.offset === from)
123 : -1;
124 const nextInvocations = ordered
125 .filter((invocation) => invocation.offset <= from || invocation.offset >= to)
126 .map((invocation, index) => {
127 const shiftAtInsertion = from === to
128 && invocation.offset === to
129 && (anchorIndex < 0 || index > anchorIndex);
130 const shiftAfterRange = invocation.offset > to || (from < to && invocation.offset === to);
131 return {
132 ...invocation,
133 offset: shiftAtInsertion || shiftAfterRange ? invocation.offset + delta : invocation.offset,
134 };
135 });
136 return {
137 text: text.slice(0, from) + value + text.slice(to),
138 invocations: sortComposerInvocations(nextInvocations),
139 };
140 }
141
142 export function trimInvocationDraft(
143 text: string,
144 invocations: ComposerInvocation[],
145 ): { text: string; invocations: ComposerInvocation[] } {
146 const trimmedStart = text.trimStart();
147 const leading = text.length - trimmedStart.length;
148 const trimmed = trimmedStart.trimEnd();
149 return {
150 text: trimmed,
151 invocations: sortComposerInvocations(invocations.map((invocation) => ({
152 ...invocation,
153 offset: Math.max(0, Math.min(trimmed.length, invocation.offset - leading)),
154 }))),
155 };
156 }
157
158 export function serializeInvocationSubmit(text: string, invocations: ComposerInvocation[]): string {
159 const ordered = sortComposerInvocations(invocations);
160 if (ordered.length === 0) return text;
161
162 let cursor = 0;
163 let output = "";
164 for (const invocation of ordered) {
165 const offset = Math.max(cursor, Math.min(text.length, invocation.offset));
166 output += text.slice(cursor, offset);
167 if (output && !/\s$/.test(output)) output += " ";
168 output += `/${invocation.command.name}`;
169 if (offset < text.length && !/^\s/.test(text.slice(offset))) output += " ";
170 cursor = offset;
171 }
172 output += text.slice(cursor);
173 return output;
174 }
175
176 function invocationBody(submitText: string): string {
177 const sessionQuestionMarker = "当前用户问题:\n";
178 const markerIndex = submitText.lastIndexOf(sessionQuestionMarker);
179 return (markerIndex >= 0 ? submitText.slice(markerIndex + sessionQuestionMarker.length) : submitText).trim();
180 }
181
182 type SlashMatch = { start: number; end: number; name: string };
183
184 function slashMatches(text: string): SlashMatch[] {
185 const re = new RegExp(`/${invocationNamePattern}(?=\\s|$)`, "g");
186 return Array.from(text.matchAll(re), (match) => ({
187 start: match.index ?? 0,
188 end: (match.index ?? 0) + match[0].length,
189 name: match[0].slice(1),
190 }));
191 }
192
193 function chunkVariants(chunk: string): string[] {
194 const values = [chunk];
195 if (chunk.startsWith(" ")) values.push(chunk.slice(1));
196 if (chunk.endsWith(" ")) values.push(chunk.slice(0, -1));
197 if (chunk.startsWith(" ") && chunk.endsWith(" ") && chunk.length > 1) values.push(chunk.slice(1, -1));
198 return Array.from(new Set(values));
199 }
200
201 function segmentsForSelection(
202 submit: string,
203 display: string,
204 matches: SlashMatch[],
205 mask: number,
206 invocationMetadata: InvocationMetadataMap,
207 ): InvocationTextSegment[] | null {
208 const selected = matches.filter((_, index) => (mask & (1 << index)) !== 0);
209 if (selected.length === 0) return null;
210
211 const normalizedDisplay = display.trim();
212 const chunks: string[] = [];
213 let cursor = 0;
214 selected.forEach((match) => {
215 chunks.push(submit.slice(cursor, match.start));
216 cursor = match.end;
217 });
218 chunks.push(submit.slice(cursor));
219
220 let resolved: { text: string; offsets: number[] } | null = null;
221 const resolve = (index: number, text: string, offsets: number[]) => {
222 if (resolved || !normalizedDisplay.startsWith(text)) return;
223 if (index === chunks.length) {
224 if (text === normalizedDisplay) resolved = { text, offsets };
225 return;
226 }
227 for (const variant of chunkVariants(chunks[index])) {
228 const nextText = text + variant;
229 if (!normalizedDisplay.startsWith(nextText)) continue;
230 const nextOffsets = index < selected.length ? [...offsets, nextText.length] : offsets;
231 resolve(index + 1, nextText, nextOffsets);
232 }
233 };
234 resolve(0, "", []);
235 if (!resolved) return null;
236
237 const segments: InvocationTextSegment[] = [];
238 let textCursor = 0;
239 selected.forEach((match, index) => {
240 const offset = resolved!.offsets[index];
241 if (offset > textCursor) {
242 segments.push({ type: "text", content: normalizedDisplay.slice(textCursor, offset), start: textCursor });
243 }
244 segments.push({
245 type: "invocation",
246 offset,
247 invocation: {
248 name: match.name,
249 label: invocationLabel(match.name),
250 source: match.name.includes(":") ? match.name.split(":").slice(0, -1).join(":") : undefined,
251 kind: invocationMetadata[match.name]?.kind ?? (knownSubagents.has(match.name) ? "subagent" : "skill"),
252 color: invocationMetadata[match.name]?.color,
253 },
254 });
255 textCursor = offset;
256 });
257 if (textCursor < normalizedDisplay.length) {
258 segments.push({ type: "text", content: normalizedDisplay.slice(textCursor), start: textCursor });
259 }
260 return segments;
261 }
262
263 // hydratedSlashFallbackSegments restores badges for a hydrated structured
264 // message: session reload resolves the recorded display — the serialized
265 // slash form ("/name task") — while the submit side is the composed model
266 // text with no slash tokens, so the display/submit pairing above never
267 // matches. Only the dominant serialized shape is restored: consecutive
268 // known-command tokens at the very start followed by the task text. Prose
269 // slashes, unknown names, and mid-text entities all bail to plain text.
270 function hydratedSlashFallbackSegments(
271 display: string,
272 invocationMetadata: InvocationMetadataMap,
273 ): InvocationTextSegment[] | null {
274 const matches = slashMatches(display);
275 if (matches.length === 0 || matches.length > 10) return null;
276 const leading: SlashMatch[] = [];
277 let cursor = 0;
278 for (const match of matches) {
279 if (match.start !== cursor || !invocationMetadata[match.name]) break;
280 leading.push(match);
281 cursor = display[match.end] === " " ? match.end + 1 : match.end;
282 }
283 if (leading.length === 0 || leading.length !== matches.length) return null;
284 const segments: InvocationTextSegment[] = leading.map((match) => ({
285 type: "invocation" as const,
286 offset: 0,
287 invocation: {
288 name: match.name,
289 label: invocationLabel(match.name),
290 source: match.name.includes(":") ? match.name.split(":").slice(0, -1).join(":") : undefined,
291 kind: invocationMetadata[match.name]?.kind ?? (knownSubagents.has(match.name) ? "subagent" : "skill"),
292 color: invocationMetadata[match.name]?.color,
293 },
294 }));
295 const remainder = display.slice(cursor);
296 if (remainder) segments.push({ type: "text", content: remainder, start: 0 });
297 return segments;
298 }
299
300 export function invocationSegmentsFromMessage(
301 displayText: string,
302 submitText?: string,
303 invocationMetadata: InvocationMetadataMap = {},
304 ): InvocationTextSegment[] {
305 const display = displayText.trim();
306 const submit = invocationBody(submitText?.trim() ?? "");
307 if (!submit || submit === display) return [{ type: "text", content: display, start: 0 }];
308
309 const matches = slashMatches(submit);
310 if (matches.length > 0 && matches.length <= 10) {
311 const masks = 1 << matches.length;
312 for (let mask = masks - 1; mask > 0; mask -= 1) {
313 const segments = segmentsForSelection(submit, display, matches, mask, invocationMetadata);
314 if (segments) return segments;
315 }
316 }
317 return hydratedSlashFallbackSegments(display, invocationMetadata) ?? [{ type: "text", content: display, start: 0 }];
318 }
319
320 export function invocationDisplayFromMessage(displayText: string, submitText?: string): InvocationDisplay | null {
321 const segment = invocationSegmentsFromMessage(displayText, submitText).find((item) => item.type === "invocation");
322 return segment?.type === "invocation" ? segment.invocation : null;
323 }
324
324 lines TYPESCRIPT