Skip to content

Commit c99c3ad

Browse files
fix: adapt AI assistant streaming answer parsing (#319)
Co-authored-by: JounQin <admin@1stg.me>
1 parent 7bfb086 commit c99c3ad

5 files changed

Lines changed: 348 additions & 30 deletions

File tree

.changeset/many-guests-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@alauda/doom": patch
3+
---
4+
5+
fix: adapt AI assistant streaming answer parsing

.gitignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,20 @@ node_modules
1414
/test.*
1515
*.log
1616
*.tsbuildinfo
17+
18+
# Local AI code agent configs
19+
.codex
20+
.codex/
21+
.claude
22+
.claude/
23+
.continue
24+
.continue/
25+
.cursor
26+
.cursor/
27+
.gemini
28+
.gemini/
29+
.roo
30+
.roo/
31+
.windsurf
32+
.windsurf/
33+
.aider*

packages/doom/src/global/Intelligence/AIAssistant/index.tsx

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import { Preamble } from './Preamble/index.tsx'
1717
import { ResizableUserInput } from './ResizableUserInput/index.tsx'
1818
import { Thinking } from './Thinking.tsx'
1919
import type { ChatMessage } from './types.ts'
20-
import { parseStreamContent } from './utils.ts'
20+
import {
21+
consumeSSEEvents,
22+
getAnswerDelta,
23+
parseStreamContent,
24+
} from './utils.ts'
2125

2226
import CloseIcon from '@alauda/doom/assets/close.svg?react'
2327
import LogoutIcon from '@alauda/doom/assets/logout.svg?react'
@@ -31,8 +35,6 @@ export interface AIAssistantProps {
3135
onCleanup?: () => void
3236
}
3337

34-
let textDecoder: TextDecoder | undefined
35-
3638
export const AIAssistant = ({ open, onOpenChange }: AIAssistantProps) => {
3739
const t = useTranslation()
3840

@@ -132,7 +134,7 @@ export const AIAssistant = ({ open, onOpenChange }: AIAssistantProps) => {
132134

133135
const sessionId = sessionIdRef.current
134136

135-
const res = await xfetch('/smart/api/smart_answer', {
137+
const res = await xfetch('/smart/api/smart_answer_with_search', {
136138
type: null,
137139
method: ApiMethod.POST,
138140
body: {
@@ -141,39 +143,80 @@ export const AIAssistant = ({ open, onOpenChange }: AIAssistantProps) => {
141143
},
142144
})
143145

144-
textDecoder ??= new TextDecoder()
146+
const textDecoder = new TextDecoder()
145147

146148
let text = ''
149+
let sseBuffer = ''
150+
let sseEvent: string | undefined
147151

148152
for await (const chunk_ of res.body! as unknown as AsyncIterable<Uint8Array>) {
149153
if (sessionId !== sessionIdRef.current) {
150154
break
151155
}
152156

153-
const chunk = textDecoder.decode(chunk_)
157+
sseBuffer += textDecoder.decode(chunk_, { stream: true })
154158

155-
text += chunk
156-
.replace(/data: (?:\n\n)?/g, '')
157-
.replace(/(?<!\n)\n\n(?!\n)/g, '')
158-
.replace(/(?<!\n)\n\n\n\n(?!\n)/g, '\n')
159-
.replace(/\n{2,}/g, '\n')
159+
const result = consumeSSEEvents(sseBuffer, sseEvent)
160+
const { events, remainder } = result
161+
sseBuffer = remainder
162+
sseEvent = result.event
160163

161-
const parsed = parseStreamContent(text)
164+
let updated = false
165+
166+
for (const event of events) {
167+
const delta = getAnswerDelta(event)
168+
if (!delta) {
169+
continue
170+
}
171+
172+
text += delta
173+
updated = true
174+
}
162175

163-
if (
164-
!parsed.refDocs.length &&
165-
!parsed.thinkingProcess &&
166-
!parsed.content
167-
) {
176+
if (!updated) {
168177
continue
169178
}
170179

180+
const parsed = parseStreamContent(text)
181+
171182
flushMessages((messages) => [
172183
...messages.slice(0, index),
173184
{ ...messages[index], ...parsed },
174185
...messages.slice(index + 1),
175186
])
176187
}
188+
189+
if (sessionId !== sessionIdRef.current) {
190+
return
191+
}
192+
193+
sseBuffer += textDecoder.decode()
194+
195+
const { events } = consumeSSEEvents(`${sseBuffer}\n`, sseEvent)
196+
197+
let updated = false
198+
199+
for (const event of events) {
200+
const delta = getAnswerDelta(event)
201+
if (!delta) {
202+
continue
203+
}
204+
205+
text += delta
206+
updated = true
207+
}
208+
209+
if (!updated) {
210+
return
211+
}
212+
213+
const parsed = parseStreamContent(text)
214+
215+
flushMessages((messages) => [
216+
...messages.slice(0, index),
217+
{ ...messages[index], ...parsed },
218+
...messages.slice(index + 1),
219+
])
177220
}
178221

179222
const [loading, setLoading] = useState(false)

packages/doom/src/global/Intelligence/AIAssistant/utils.ts

Lines changed: 157 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,161 @@ import { isObject } from 'es-toolkit/compat'
44

55
import type { RefDoc } from './types.js'
66

7+
export interface SSEEvent {
8+
data: string
9+
event?: string
10+
}
11+
12+
export interface SSEConsumeResult {
13+
event?: string
14+
events: SSEEvent[]
15+
remainder: string
16+
}
17+
18+
const IGNORED_SSE_EVENT_TYPES =
19+
/(?:^|[._-])(?:metadata|trace|updates?)(?:$|[._-])/i
20+
21+
const STREAM_TEXT_KEYS = ['delta', 'text', 'content', 'answer', 'message']
22+
723
export const unicodeToString = (unicodeStr: string) =>
824
unicodeStr.replace(/\\u([0-9a-fA-F]{4})/g, (_match, p1: string) =>
925
String.fromCharCode(Number.parseInt(p1, 16)),
1026
)
1127

28+
const isRecord = (value: unknown): value is Record<string, unknown> =>
29+
isObject(value) && !Array.isArray(value)
30+
1231
export const removePrefix = (
1332
prefix: string | null | undefined,
1433
text: string,
1534
) => (prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text)
1635

36+
const parseSSEFieldValue = (line: string, field: string) => {
37+
const value = line.slice(field.length + 1)
38+
return value.startsWith(' ') ? value.slice(1) : value
39+
}
40+
41+
const getStringValue = (value: unknown): string | undefined => {
42+
if (typeof value === 'string') {
43+
return value
44+
}
45+
46+
if (Array.isArray(value)) {
47+
const text = value.map((item) => getStringValue(item) || '').join('')
48+
return text || undefined
49+
}
50+
51+
if (!isRecord(value)) {
52+
return
53+
}
54+
55+
for (const key of STREAM_TEXT_KEYS) {
56+
const text = getStringValue(value[key])
57+
if (text) {
58+
return text
59+
}
60+
}
61+
}
62+
63+
const getChoicesDelta = (choices: unknown): string | undefined => {
64+
if (!Array.isArray(choices)) {
65+
return
66+
}
67+
68+
const text = choices.map((choice) => getStringValue(choice) || '').join('')
69+
70+
return text || undefined
71+
}
72+
73+
export const consumeSSEEvents = (
74+
buffer: string,
75+
event?: string,
76+
): SSEConsumeResult => {
77+
const events: SSEEvent[] = []
78+
79+
let remaining = buffer
80+
let currentEvent = event
81+
82+
let lineBoundary = remaining.match(/\r?\n/)
83+
84+
while (lineBoundary?.index != null) {
85+
const line = remaining.slice(0, lineBoundary.index)
86+
remaining = remaining.slice(lineBoundary.index + lineBoundary[0].length)
87+
88+
if (!line) {
89+
currentEvent = undefined
90+
} else if (line.startsWith(':')) {
91+
//
92+
} else if (line.startsWith('event:')) {
93+
currentEvent = parseSSEFieldValue(line, 'event')
94+
} else if (line.startsWith('data:')) {
95+
events.push({
96+
event: currentEvent,
97+
data: parseSSEFieldValue(line, 'data'),
98+
})
99+
}
100+
101+
lineBoundary = remaining.match(/\r?\n/)
102+
}
103+
104+
return {
105+
event: currentEvent,
106+
events,
107+
remainder: remaining,
108+
}
109+
}
110+
111+
export const getAnswerDelta = ({ event, data }: SSEEvent) => {
112+
if (!data || data === '[DONE]') {
113+
return ''
114+
}
115+
116+
if (event && IGNORED_SSE_EVENT_TYPES.test(event)) {
117+
return ''
118+
}
119+
120+
try {
121+
const payload = JSON.parse(data)
122+
123+
if (typeof payload === 'string') {
124+
return payload
125+
}
126+
127+
if (!isRecord(payload)) {
128+
return ''
129+
}
130+
131+
const type = typeof payload.type === 'string' ? payload.type : undefined
132+
133+
if (type && IGNORED_SSE_EVENT_TYPES.test(type)) {
134+
return ''
135+
}
136+
137+
const choiceDelta = getChoicesDelta(payload.choices)
138+
139+
if (choiceDelta) {
140+
return choiceDelta
141+
}
142+
143+
for (const key of STREAM_TEXT_KEYS) {
144+
const text = getStringValue(payload[key])
145+
if (text) {
146+
return text
147+
}
148+
}
149+
150+
return ''
151+
} catch {
152+
return data
153+
}
154+
}
155+
17156
export function parseStreamContent(text: string) {
18-
const matchDocs = text.match(/<docs>([\s\S]*?)<\/docs>/)
19-
const docsReferences = matchDocs?.length
20-
? unicodeToString(matchDocs[1])
157+
const docsMatches = [...text.matchAll(/<docs>([\s\S]*?)<\/docs>/g)]
158+
const docsReferences = docsMatches.length
159+
? unicodeToString(docsMatches.at(-1)![1])
21160
: '[]'
161+
22162
let refDocs: RefDoc[] = []
23163
try {
24164
refDocs = (JSON.parse(docsReferences) as RefDoc[]).filter((r) =>
@@ -28,19 +168,23 @@ export function parseStreamContent(text: string) {
28168
//
29169
}
30170

31-
const matchThinks = text.match(/<think>[\s\S]*?<\/think>|^[\s\S]*?<\/think>/g)
32-
const firstMatched = matchThinks?.[0]
33-
let matched = firstMatched || matchThinks?.[1]
34-
if (matched) {
35-
matched = removePrefix(firstMatched || '', matched)
171+
const thinkMatches = [...text.matchAll(/<think>([\s\S]*?)<\/think>/g)]
172+
let thinkingProcess = thinkMatches.at(-1)?.[1]
173+
174+
if (!thinkingProcess) {
175+
const openThinkMatch = text.match(/<think>([\s\S]*)$/)
176+
thinkingProcess = openThinkMatch?.[1]
36177
}
37-
const thinkingProcess = matchThinks && matched
178+
179+
const content = text
180+
.replace(/<docs>[\s\S]*?<\/docs>/g, '')
181+
.replace(/<think>[\s\S]*?<\/think>/g, '')
182+
.replace(/<docs>[\s\S]*$/g, '')
183+
.replace(/<think>[\s\S]*$/g, '')
38184

39185
return {
40186
refDocs,
41-
thinkingProcess,
42-
content: unicodeToString(
43-
removePrefix(thinkingProcess, removePrefix(matchDocs?.[0], text)),
44-
),
187+
thinkingProcess: thinkingProcess && unicodeToString(thinkingProcess),
188+
content: unicodeToString(content),
45189
}
46190
}

0 commit comments

Comments
 (0)