| 1 | import { |
| 2 | matchInlineSkills, |
| 3 | formatProviderSkillDisplayName, |
| 4 | type InlineSkill, |
| 5 | } from "./inlineSkills.ts"; |
| 6 | import { |
| 7 | artifactTemplateFromHastProperties, |
| 8 | renderCodexInlineDirectivesAsMarkdown, |
| 9 | } from "./codexMarkdownDirectives.ts"; |
| 10 | import { codexArtifactTemplatePresentationLabel } from "./codexArtifactTemplates.ts"; |
| 11 | import { |
| 12 | buildFileLinkParentSuffixByPath, |
| 13 | splitFilePathPosition, |
| 14 | fileLinkLabel, |
| 15 | } from "./fileLinks.ts"; |
| 16 | import { |
| 17 | isMarkdownFileLinkLabel, |
| 18 | extractMarkdownLinkHrefs, |
| 19 | extractInlineCodeSpans, |
| 20 | inlineCodeFilePathCandidate, |
| 21 | resolveMarkdownFileLinkTarget, |
| 22 | } from "./markdownLinks.ts"; |
| 23 | import { upgradeLegacyContextMessage } from "./composerContextLegacy.ts"; |
| 24 | import { parseComposerContextHref } from "./composerContextReferences.ts"; |
| 25 | import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts"; |
| 26 | import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts"; |
| 27 | import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; |
| 28 | import { unified } from "unified"; |
| 29 | import remarkParse from "remark-parse"; |
| 30 | import remarkRehype from "remark-rehype"; |
| 31 | import { |
| 32 | shouldPreserveAssistantLineBreaks, |
| 33 | CHAT_MARKDOWN_REHYPE_PLUGINS, |
| 34 | CHAT_MARKDOWN_REMARK_PLUGINS, |
| 35 | CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, |
| 36 | } from "./markdownPipeline.ts"; |
| 37 | |
| 38 | // Inline wrappers (including Shiki token spans) must not split a search phrase. |
| 39 | export const THREAD_FIND_BLOCK_TAGS = new Set([ |
| 40 | "address", |
| 41 | "article", |
| 42 | "aside", |
| 43 | "blockquote", |
| 44 | "br", |
| 45 | "dd", |
| 46 | "details", |
| 47 | "div", |
| 48 | "dl", |
| 49 | "dt", |
| 50 | "figcaption", |
| 51 | "figure", |
| 52 | "footer", |
| 53 | "h1", |
| 54 | "h2", |
| 55 | "h3", |
| 56 | "h4", |
| 57 | "h5", |
| 58 | "h6", |
| 59 | "header", |
| 60 | "hr", |
| 61 | "li", |
| 62 | "main", |
| 63 | "nav", |
| 64 | "ol", |
| 65 | "p", |
| 66 | "pre", |
| 67 | "section", |
| 68 | "summary", |
| 69 | "table", |
| 70 | "tbody", |
| 71 | "td", |
| 72 | "th", |
| 73 | "thead", |
| 74 | "tr", |
| 75 | "ul", |
| 76 | ]); |
| 77 | |
| 78 | const assistantProcessor = unified() |
| 79 | .use(remarkParse) |
| 80 | .use(CHAT_MARKDOWN_REMARK_PLUGINS) |
| 81 | .use(remarkRehype, { allowDangerousHtml: true }) |
| 82 | .use(CHAT_MARKDOWN_REHYPE_PLUGINS); |
| 83 | const assistantBreaksProcessor = unified() |
| 84 | .use(remarkParse) |
| 85 | .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) |
| 86 | .use(remarkRehype, { allowDangerousHtml: true }) |
| 87 | .use(CHAT_MARKDOWN_REHYPE_PLUGINS); |
| 88 | const userProcessor = unified() |
| 89 | .use(remarkParse) |
| 90 | .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) |
| 91 | .use(remarkRehype, { allowDangerousHtml: true }); |
| 92 | |
| 93 | interface TextTree { |
| 94 | readonly type: string; |
| 95 | readonly tagName?: string; |
| 96 | readonly value?: string; |
| 97 | readonly properties?: { |
| 98 | readonly href?: unknown; |
| 99 | readonly src?: unknown; |
| 100 | readonly dataInlineCode?: unknown; |
| 101 | }; |
| 102 | readonly children?: ReadonlyArray<TextTree>; |
| 103 | } |
| 104 | |
| 105 | /** Uses the renderer's Markdown transforms, without mounting folded/virtualized rows. */ |
| 106 | function markdownThreadFindText( |
| 107 | markdown: string, |
| 108 | userMessage = false, |
| 109 | cwd?: string, |
| 110 | skills: readonly InlineSkill[] = [], |
| 111 | lineBreaks = false, |
| 112 | ): string[] { |
| 113 | const processor = userMessage |
| 114 | ? userProcessor |
| 115 | : lineBreaks |
| 116 | ? assistantBreaksProcessor |
| 117 | : assistantProcessor; |
| 118 | const tree = processor.runSync(processor.parse(markdown)); |
| 119 | const paths = [ |
| 120 | ...extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(markdown)), |
| 121 | ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []), |
| 122 | ].flatMap((href) => { |
| 123 | const target = resolveMarkdownFileLinkTarget(href, cwd); |
| 124 | return target ? [splitFilePathPosition(target).path] : []; |
| 125 | }); |
| 126 | const parentSuffixes = buildFileLinkParentSuffixByPath(paths); |
| 127 | const segments: string[] = []; |
| 128 | let text = ""; |
| 129 | const flush = () => { |
| 130 | if (text.trim()) segments.push(text); |
| 131 | text = ""; |
| 132 | }; |
| 133 | const visit = (node: TextTree, inPre = false, inlineSkills = false) => { |
| 134 | const href = node.properties?.href ?? node.properties?.src; |
| 135 | if (userMessage && typeof href === "string" && parseComposerContextHref(href)) { |
| 136 | flush(); |
| 137 | return; |
| 138 | } |
| 139 | const citation = |
| 140 | node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null; |
| 141 | if (citation) { |
| 142 | // Matches the citation chip, which shows its label instead of the link text. |
| 143 | text += assistantCitationLabel(citation); |
| 144 | return; |
| 145 | } |
| 146 | { |
| 147 | let candidate: string | null = null; |
| 148 | if (node.tagName === "a" && typeof href === "string") { |
| 149 | candidate = href; |
| 150 | } else if ( |
| 151 | node.tagName === "code" && |
| 152 | !inPre && |
| 153 | node.properties?.dataInlineCode !== undefined |
| 154 | ) { |
| 155 | candidate = inlineCodeFilePathCandidate( |
| 156 | (node.children ?? []).map((child) => child.value ?? "").join(""), |
| 157 | ); |
| 158 | } |
| 159 | const target = candidate ? resolveMarkdownFileLinkTarget(candidate, cwd) : null; |
| 160 | if (target) { |
| 161 | const labelText = (node: TextTree): string => |
| 162 | node.value ?? (node.children ?? []).map(labelText).join(""); |
| 163 | const label = labelText(node); |
| 164 | if (node.tagName === "a" && candidate && !isMarkdownFileLinkLabel(label, candidate)) |
| 165 | text += `${label} `; |
| 166 | text += fileLinkLabel(splitFilePathPosition(target), parentSuffixes); |
| 167 | return; |
| 168 | } |
| 169 | } |
| 170 | const template = artifactTemplateFromHastProperties(node.properties); |
| 171 | if (template) { |
| 172 | // Matches the card's visible text; its action button is not indexed. |
| 173 | flush(); |
| 174 | segments.push( |
| 175 | template.displayName, |
| 176 | codexArtifactTemplatePresentationLabel(template.artifactKind), |
| 177 | ); |
| 178 | return; |
| 179 | } |
| 180 | const block = THREAD_FIND_BLOCK_TAGS.has(node.tagName ?? ""); |
| 181 | if (block) flush(); |
| 182 | if (node.type === "text" || (userMessage && node.type === "raw")) { |
| 183 | let value = node.value ?? ""; |
| 184 | if (inlineSkills) { |
| 185 | let cursor = 0; |
| 186 | let rendered = ""; |
| 187 | for (const { start, end, skill } of matchInlineSkills(value, skills)) { |
| 188 | rendered += value.slice(cursor, start) + formatProviderSkillDisplayName(skill); |
| 189 | cursor = end; |
| 190 | } |
| 191 | value = rendered + value.slice(cursor); |
| 192 | } |
| 193 | text += inPre ? value : value.replace(/\r?\n/g, " "); |
| 194 | } |
| 195 | const renderSkills = |
| 196 | node.tagName === "code" || node.tagName === "a" |
| 197 | ? false |
| 198 | : inlineSkills || node.tagName === "p" || node.tagName === "li"; |
| 199 | for (const child of node.children ?? []) |
| 200 | visit(child, inPre || node.tagName === "pre", renderSkills); |
| 201 | if (block) flush(); |
| 202 | }; |
| 203 | visit(tree); |
| 204 | flush(); |
| 205 | return segments; |
| 206 | } |
| 207 | |
| 208 | /** Plans render `$skill` tokens literally (no skill chips), so they are indexed as written. */ |
| 209 | export function searchablePlanSegments(markdown: string, cwd?: string): readonly string[] { |
| 210 | return [ |
| 211 | proposedPlanTitle(markdown) ?? "Proposed plan", |
| 212 | ...markdownThreadFindText(stripDisplayedPlanMarkdown(markdown), false, cwd, []), |
| 213 | ]; |
| 214 | } |
| 215 | |
| 216 | export function searchableMessageSegments( |
| 217 | message: Pick<OrchestrationV2ConversationMessage, "role" | "text" | "streaming" | "context">, |
| 218 | cwd?: string, |
| 219 | skills: readonly InlineSkill[] = [], |
| 220 | ): readonly string[] | null { |
| 221 | if (message.role === "user") { |
| 222 | const text = message.context ? message.text : upgradeLegacyContextMessage(message.text).text; |
| 223 | return markdownThreadFindText(text, true, cwd, skills); |
| 224 | } |
| 225 | if (message.role !== "assistant") return null; |
| 226 | return markdownThreadFindText( |
| 227 | message.text || (message.streaming ? "" : "(empty response)"), |
| 228 | false, |
| 229 | cwd, |
| 230 | skills, |
| 231 | shouldPreserveAssistantLineBreaks(message.text), |
| 232 | ); |
| 233 | } |