Skip to content

Commit 36d74ca

Browse files
authored
fix(stage-ui,stage-web): emit literals asap and show streaming messages (#451)
1 parent c00f1fc commit 36d74ca

File tree

3 files changed

+67
-21
lines changed

3 files changed

+67
-21
lines changed

apps/stage-web/src/components/Widgets/ChatHistory.vue

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useI18n } from 'vue-i18n'
88
const chatHistoryRef = ref<HTMLDivElement>()
99
1010
const { t } = useI18n()
11-
const { messages, sending } = storeToRefs(useChatStore())
11+
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
1212
1313
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
1414
@@ -104,6 +104,33 @@ onTokenLiteral(async () => {
104104
</div>
105105
</div>
106106
</div>
107+
<div v-if="sending" flex mr="12">
108+
<div
109+
flex="~ col" border="2 solid primary-200/50 dark:primary-500/50" shadow="md primary-200/50 dark:none" min-w-20
110+
rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:primary-500/25"
111+
>
112+
<div>
113+
<span text-xs text="primary-400/90 dark:primary-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
114+
</div>
115+
<div v-if="streamingMessage.content" class="break-words" text="xs primary-400">
116+
<div v-for="(slice, sliceIndex) in streamingMessage.slices" :key="sliceIndex">
117+
<div v-if="slice.type === 'tool-call'">
118+
<div
119+
p="1" border="1 solid primary-200" rounded-lg m="y-1" bg="primary-100"
120+
>
121+
Called: <code>{{ slice.toolCall.toolName }}</code>
122+
</div>
123+
</div>
124+
<div v-else-if="slice.type === 'tool-call-result'" /> <!-- this line should be unreachable -->
125+
<MarkdownRenderer
126+
v-else
127+
:content="slice.text"
128+
/>
129+
</div>
130+
</div>
131+
<div v-else i-eos-icons:three-dots-loading />
132+
</div>
133+
</div>
107134
</div>
108135
</div>
109136
</template>

packages/stage-ui/src/composables/llmmarkerParser.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
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
@@ -15,9 +18,15 @@
1518
export 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
}

packages/stage-ui/src/stores/chat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export const useChatStore = defineStore('chat', () => {
141141
await hook(special)
142142
}
143143
},
144+
minLiteralEmitLength: 24, // Avoid emitting literals too fast. This is a magic number and can be changed later.
144145
})
145146

146147
const toolCallQueue = useQueue<ChatSlices>({

0 commit comments

Comments
 (0)