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