1+ const TAG_OPEN = '<|'
2+ const TAG_CLOSE = '|>'
3+
14/**
25 * A streaming parser for LLM responses that contain special markers (e.g., for tool calls).
36 * This composable is designed to be efficient and robust, using a regular expression
1518export function useLlmmarkerParser ( options : {
1619 onLiteral ?: ( literal : string ) => void | Promise < void >
1720 onSpecial ?: ( special : string ) => void | Promise < void >
21+ /**
22+ * The minimum length of text required to emit a literal part.
23+ * Useful for avoiding emitting literal parts too fast.
24+ */
25+ minLiteralEmitLength ?: number
1826} ) {
27+ const minLiteralEmitLength = Math . max ( 1 , options . minLiteralEmitLength ?? 1 )
1928 let buffer = ''
20- const tagRegex = / ( < \| . * ? \| > ) /
29+ let inTag = false
2130
2231 return {
2332 /**
@@ -29,27 +38,35 @@ export function useLlmmarkerParser(options: {
2938 async consume ( textPart : string ) {
3039 buffer += textPart
3140
32- // The regex splits the buffer by tags, keeping the tags in the result array.
33- const parts = buffer . split ( tagRegex )
34-
35- // The last element of the array is the remainder of the string after the last
36- // complete tag. It could be a partial literal or a partial tag. We keep it
37- // in the buffer for the next consume call.
38- const processableParts = parts . slice ( 0 , - 1 )
39- buffer = parts [ parts . length - 1 ] || ''
41+ while ( buffer . length > 0 ) {
42+ if ( ! inTag ) {
43+ const openTagIndex = buffer . indexOf ( TAG_OPEN )
44+ if ( openTagIndex < 0 ) {
45+ if ( buffer . length - 1 >= minLiteralEmitLength ) {
46+ const emit = buffer . slice ( 0 , - 1 )
47+ buffer = buffer [ buffer . length - 1 ]
48+ await options . onLiteral ?.( emit )
49+ }
50+ break
51+ }
4052
41- for ( const part of processableParts ) {
42- if ( ! part )
43- continue // Skip empty strings that can result from the split.
44-
45- // Check if the part is a tag or a literal.
46- if ( tagRegex . test ( part ) ) {
47- // Extract the content from inside the tag and pass it to the callback.
48- const specialContent = part . slice ( 2 , - 2 )
49- await options . onSpecial ?.( specialContent )
53+ if ( openTagIndex > 0 ) {
54+ const emit = buffer . slice ( 0 , openTagIndex )
55+ buffer = buffer . slice ( openTagIndex )
56+ await options . onLiteral ?.( emit )
57+ }
58+ inTag = true
5059 }
5160 else {
52- await options . onLiteral ?.( part )
61+ const closeTagIndex = buffer . indexOf ( TAG_CLOSE )
62+ if ( closeTagIndex < 0 ) {
63+ break
64+ }
65+
66+ const emit = buffer . slice ( 0 , closeTagIndex + TAG_CLOSE . length )
67+ buffer = buffer . slice ( closeTagIndex + TAG_CLOSE . length )
68+ await options . onSpecial ?.( emit )
69+ inTag = false
5370 }
5471 }
5572 } ,
@@ -60,7 +77,8 @@ export function useLlmmarkerParser(options: {
6077 * This should be called after the stream has ended.
6178 */
6279 async end ( ) {
63- if ( buffer ) {
80+ // Incomplete tag should not be emitted as literals.
81+ if ( ! inTag && buffer . length > 0 ) {
6482 await options . onLiteral ?.( buffer )
6583 buffer = ''
6684 }
0 commit comments