| 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 |