Skip to content

Commit 7be5509

Browse files
committed
feat(markdown): implement formatting functions for markdown blocks and add tests
- Added `formatObjectToMarkdownBlock`, `escapeBackslashes`, and `formatPlaceholder` functions to handle object formatting and escaping in markdown. - Refactored `customAstToString` to utilize a dedicated remark processor. - Introduced unit tests for the new formatting functions to ensure proper functionality and error handling.
1 parent b52e1b2 commit 7be5509

File tree

4 files changed

+293
-60
lines changed

4 files changed

+293
-60
lines changed

ee/tabby-ui/components/chat/question-answer.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ function UserMessageCard(props: { message: UserMessage }) {
142142
}
143143

144144
const processedContent = useMemo(() => {
145-
return convertContextBlockToPlaceholder(message.content)
145+
const afterConvert = convertContextBlockToPlaceholder(message.content)
146+
console.log('afterConvert', afterConvert)
147+
return afterConvert
146148
}, [message.content])
147149

148150
return (

ee/tabby-ui/lib/utils/chat.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
PLACEHOLDER_FILE_REGEX,
2626
PLACEHOLDER_SYMBOL_REGEX
2727
} from '../constants/regex'
28-
import { convertContextBlockToPlaceholder } from './markdown'
28+
import { convertContextBlockToPlaceholder, formatObjectToMarkdownBlock } from './markdown'
2929

3030
export const isCodeSourceContext = (kind: ContextSourceKind) => {
3131
return [
@@ -326,7 +326,7 @@ export async function processingPlaceholder(
326326
): Promise<string> {
327327
let processedMessage = message
328328
if (hasChangesCommand(processedMessage) && options.getChanges) {
329-
try {
329+
try {
330330
const changes = await options.getChanges({})
331331
const gitChanges = convertChangeItemsToContextContent(changes)
332332
processedMessage = processedMessage.replaceAll(
@@ -354,8 +354,7 @@ export async function processingPlaceholder(
354354
})
355355
let replacement = ''
356356
if (content) {
357-
const fileInfoJSON = JSON.stringify(fileInfo).replace(/"/g, '\\"')
358-
replacement = `\n\`\`\`context label='file' object='${fileInfoJSON}'\n${content}\n\`\`\`\n`
357+
replacement = formatObjectToMarkdownBlock('file', fileInfo, content)
359358
}
360359
processedMessage = processedMessage.replace(match[0], replacement)
361360
tempMessage = tempMessage.replace(match[0], replacement)
@@ -381,8 +380,7 @@ export async function processingPlaceholder(
381380
})
382381
let replacement = ''
383382
if (content) {
384-
const symbolInfoJSON = JSON.stringify(symbolInfo).replace(/"/g, '\\"')
385-
replacement = `\n\`\`\`context label='symbol' object='${symbolInfoJSON}'\n${content}\n\`\`\`\n`
383+
replacement = formatObjectToMarkdownBlock('symbol', symbolInfo, content)
386384
}
387385
processedMessage = processedMessage.replace(match[0], replacement)
388386
tempMessage = tempMessage.replace(match[0], replacement)

ee/tabby-ui/lib/utils/markdown.ts

+106-53
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,29 @@
11
import { Parent, Root, RootContent } from 'mdast'
22
import { remark } from 'remark'
33
import remarkStringify from 'remark-stringify'
4+
import { Options } from 'remark-stringify'
45

5-
/**
6-
* Custom stringification of AST to preserve special patterns
7-
* @param ast AST to stringify
8-
* @returns Plain string representation
9-
*/
10-
export function customAstToString(ast: Root): string {
11-
let result = ''
12-
for (const node of ast.children) {
13-
result += nodeToString(node) + '\n'
14-
}
15-
return result.trim()
6+
const REMARK_STRINGIFY_OPTIONS: Options = {
7+
bullet: '*',
8+
emphasis: '*',
9+
fences: true,
10+
listItemIndent: 'one',
11+
tightDefinitions: true
1612
}
1713

18-
/**
19-
* Convert a single node to string
20-
* @param node AST node
21-
* @returns String representation
22-
*/
23-
function nodeToString(node: any): string {
24-
switch (node.type) {
25-
case 'paragraph':
26-
return paragraphToString(node)
27-
case 'text':
28-
return node.value
29-
default:
30-
const processor = remark().use(remarkStringify)
31-
return processor.stringify({ type: 'root', children: [node] }).trim()
32-
}
33-
}
3414

35-
/**
36-
* Convert paragraph node to string
37-
* @param node Paragraph node
38-
* @returns String representation
39-
*/
40-
function paragraphToString(node: any): string {
41-
return childrenToString(node)
15+
function createRemarkProcessor() {
16+
return remark().use(remarkStringify, REMARK_STRINGIFY_OPTIONS)
4217
}
4318

4419
/**
45-
* Process children of a node and join them
46-
* @param node Parent node
47-
* @returns Combined string of all children
20+
* Custom stringification of AST using remarkStringify
21+
* @param ast AST to stringify
22+
* @returns Plain string representation
4823
*/
49-
function childrenToString(node: any): string {
50-
if (!node.children || node.children.length === 0) {
51-
return ''
52-
}
53-
return node.children.map((child: any) => nodeToString(child)).join('')
24+
export function customAstToString(ast: Root): string {
25+
const processor = createRemarkProcessor()
26+
return processor.stringify(ast).trim()
5427
}
5528

5629
export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
@@ -62,8 +35,7 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
6235
if (node.type === 'code' && node.meta) {
6336
node.meta?.split(' ').forEach(item => {
6437
const [key, rawValue] = item.split(/=(.+)/)
65-
const value = rawValue?.replace(/^['"]|['"]$/g, '') || ''
66-
metas[key] = value
38+
metas[key] = rawValue
6739
})
6840
}
6941

@@ -94,10 +66,11 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
9466
case 'file':
9567
if (metas['object']) {
9668
try {
97-
const fileObject = JSON.parse(
98-
metas['object'].replace(/\\"/g, '"').replace(/\\/g, '/')
99-
)
100-
finalCommandText = `[[file:${JSON.stringify(fileObject)}]]`
69+
finalCommandText = formatPlaceholder('file', metas['object'])
70+
if (!finalCommandText) {
71+
shouldProcessNode = false
72+
newChildren.push(node)
73+
}
10174
} catch (error) {
10275
shouldProcessNode = false
10376
newChildren.push(node)
@@ -107,10 +80,11 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
10780
case 'symbol':
10881
if (metas['object']) {
10982
try {
110-
const symbolObject = JSON.parse(
111-
metas['object'].replace(/\\"/g, '"').replace(/\\/g, '/')
112-
)
113-
finalCommandText = `[[symbol:${JSON.stringify(symbolObject)}]]`
83+
finalCommandText = formatPlaceholder('symbol', metas['object'])
84+
if (!finalCommandText) {
85+
shouldProcessNode = false
86+
newChildren.push(node)
87+
}
11488
} catch (error) {
11589
shouldProcessNode = false
11690
newChildren.push(node)
@@ -180,7 +154,7 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
180154
}
181155

182156
export function processContextCommand(input: string): string {
183-
const processor = remark()
157+
const processor = createRemarkProcessor()
184158
const ast = processor.parse(input) as Root
185159
ast.children = processCodeBlocksWithLabel(ast)
186160
return customAstToString(ast)
@@ -189,3 +163,82 @@ export function processContextCommand(input: string): string {
189163
export function convertContextBlockToPlaceholder(input: string): string {
190164
return processContextCommand(input)
191165
}
166+
167+
/**
168+
* Format an object into a markdown code block with proper metadata
169+
* @param label The label for the code block (e.g., 'file', 'symbol')
170+
* @param obj The object to format
171+
* @param content The content to include in the code block
172+
* @returns A formatted markdown code block string
173+
*/
174+
export function formatObjectToMarkdownBlock(
175+
label: string,
176+
obj: any,
177+
content: string
178+
): string {
179+
try {
180+
// Convert the object to a JSON string
181+
const objJSON = JSON.stringify(obj)
182+
183+
let safeContent = content
184+
185+
if (safeContent.includes('```')) {
186+
safeContent = safeContent.replace(/```/g, '` ` `')
187+
}
188+
189+
if (safeContent.trimEnd().endsWith('`')) {
190+
safeContent = safeContent + ' '
191+
}
192+
193+
const codeNode: Root = {
194+
type: 'root',
195+
children: [
196+
{
197+
type: 'code',
198+
lang: 'context',
199+
meta: `label=${label} object=${objJSON}`,
200+
value: safeContent
201+
} as RootContent
202+
]
203+
}
204+
205+
const processor = createRemarkProcessor()
206+
207+
const res = '\n' + processor.stringify(codeNode).trim() + '\n'
208+
console.log('res', res)
209+
return res;
210+
} catch (error) {
211+
console.error(`Error formatting ${label} to markdown block:`, error)
212+
return `\n*Error formatting ${label}*\n`
213+
}
214+
}
215+
216+
/**
217+
* Escape backslashes in a string to handle Windows filepath issues
218+
* @param str The string to process
219+
* @returns The string with properly escaped backslashes
220+
*/
221+
export function escapeBackslashes(str: string): string {
222+
if (!str) return ''
223+
return str.replace(/\\/g, '\\\\')
224+
}
225+
226+
/**
227+
* Format a placeholder with proper backslash escaping
228+
* @param type The type of placeholder (e.g., 'file', 'symbol')
229+
* @param objStr The string representation of the object
230+
* @returns The formatted placeholder text
231+
*/
232+
export function formatPlaceholder(type: string, objStr: string): string {
233+
if (!objStr) return ''
234+
235+
try {
236+
console.log('objStr', JSON.parse(objStr))
237+
// Escape backslashes to ensure they're preserved
238+
const escapedStr = escapeBackslashes(objStr)
239+
return `[[${type}:${escapedStr}]]`
240+
} catch (error) {
241+
console.error(`Error formatting ${type} placeholder:`, error)
242+
return ''
243+
}
244+
}

0 commit comments

Comments
 (0)