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