Skip to content

Commit 39468f7

Browse files
Jinyu XuJinyu Xu
authored andcommitted
fix: align mobile AI stream recovery
1 parent dc49963 commit 39468f7

3 files changed

Lines changed: 103 additions & 23 deletions

File tree

src/api/index.js

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -771,26 +771,36 @@ export const aiChatApi = {
771771
return event
772772
}
773773

774-
while (!streamComplete) {
775-
const { value, done } = await reader.read()
776-
if (done) break
777-
buffer += decoder.decode(value, { stream: true })
778-
const parts = buffer.split(/\r?\n\r?\n/)
779-
buffer = parts.pop() || ''
780-
for (const part of parts) {
781-
if (await handlePart(part) === 'done') {
782-
streamComplete = true
774+
try {
775+
while (!streamComplete) {
776+
const { value, done } = await reader.read()
777+
if (done) {
778+
buffer += decoder.decode()
783779
break
784780
}
781+
buffer += decoder.decode(value, { stream: true })
782+
const parts = buffer.split(/\r?\n\r?\n/)
783+
buffer = parts.pop() || ''
784+
for (const part of parts) {
785+
if (await handlePart(part) === 'done') {
786+
streamComplete = true
787+
break
788+
}
789+
}
790+
if (streamComplete) {
791+
try {
792+
await reader.cancel()
793+
} catch (_) {}
794+
}
785795
}
786-
if (streamComplete) {
787-
try {
788-
await reader.cancel()
789-
} catch (_) {}
796+
if (!streamComplete && buffer.trim()) {
797+
streamComplete = await handlePart(buffer) === 'done'
790798
}
791-
}
792-
if (!streamComplete && buffer.trim()) {
793-
streamComplete = await handlePart(buffer) === 'done'
799+
} catch (error) {
800+
try {
801+
await reader.cancel()
802+
} catch (_) {}
803+
throw error
794804
}
795805
return { completed: streamComplete }
796806
},

src/views/ai-hub/index.vue

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@
122122
</div>
123123
</template>
124124
</div>
125+
<div v-if="msg.streamWarning" class="stream-warning">
126+
<van-icon name="warning-o" />
127+
<span>{{ msg.streamWarning }}</span>
128+
</div>
125129
<van-loading v-if="msg.loading" size="18" />
126130
</div>
127131
<div v-if="msg.role === 'assistant' && msg.content && !msg.loading" class="bubble-tools">
@@ -339,6 +343,8 @@ const COPY = {
339343
strategyPromptNeeded: '请先写一点策略想法',
340344
generateFailed: '生成失败',
341345
streamInterrupted: '连接中断,已保留当前内容,请重试。',
346+
streamIncomplete: '响应未正常结束,请重试。',
347+
outputLimit: '回答已达到输出上限,当前内容可能不完整。',
342348
desktopOnly: '手机端仅支持使用与监控,请在电脑端完成代码编辑或回测。',
343349
taskDiagnose: '诊断标的',
344350
taskDiagnoseDesc: '趋势、量能、支撑阻力和风险',
@@ -384,6 +390,8 @@ const COPY = {
384390
strategyPromptNeeded: '請先寫一點策略想法',
385391
generateFailed: '生成失敗',
386392
streamInterrupted: '連線中斷,已保留目前內容,請重試。',
393+
streamIncomplete: '回應未正常結束,請重試。',
394+
outputLimit: '回答已達到輸出上限,目前內容可能不完整。',
387395
desktopOnly: '手機端僅支援使用與監控,請在電腦端完成程式碼編輯或回測。',
388396
taskDiagnose: '診斷標的',
389397
taskDiagnoseDesc: '趨勢、量能、支撐阻力和風險',
@@ -429,6 +437,8 @@ const COPY = {
429437
strategyPromptNeeded: 'Write a short strategy idea first',
430438
generateFailed: 'Generation failed',
431439
streamInterrupted: 'The connection was interrupted. The current response was kept; please retry.',
440+
streamIncomplete: 'The response did not finish correctly. Please retry.',
441+
outputLimit: 'The response reached the output limit and may be incomplete.',
432442
desktopOnly: 'Mobile supports usage and monitoring only. Use desktop for code editing or backtesting.',
433443
taskDiagnose: 'Diagnose',
434444
taskDiagnoseDesc: 'Trend, volume, levels, and risk',
@@ -474,6 +484,8 @@ const COPY = {
474484
strategyPromptNeeded: 'まず戦略アイデアを入力してください',
475485
generateFailed: '生成に失敗しました',
476486
streamInterrupted: '接続が中断されました。現在の内容を保持しました。再試行してください。',
487+
streamIncomplete: '応答が正常に完了しませんでした。もう一度お試しください。',
488+
outputLimit: '出力上限に達したため、回答が不完全な可能性があります。',
477489
desktopOnly: 'モバイルは利用と監視専用です。コード編集やバックテストはデスクトップで行ってください。',
478490
taskDiagnose: '銘柄診断',
479491
taskDiagnoseDesc: 'トレンド、出来高、重要水準、リスク',
@@ -519,6 +531,8 @@ const COPY = {
519531
strategyPromptNeeded: '먼저 전략 아이디어를 입력하세요',
520532
generateFailed: '생성 실패',
521533
streamInterrupted: '연결이 중단되었습니다. 현재 내용을 유지했으니 다시 시도해 주세요.',
534+
streamIncomplete: '응답이 정상적으로 완료되지 않았습니다. 다시 시도해 주세요.',
535+
outputLimit: '출력 한도에 도달하여 답변이 불완전할 수 있습니다.',
522536
desktopOnly: '모바일은 사용 및 모니터링 전용입니다. 코드 편집과 백테스트는 데스크톱에서 진행하세요.',
523537
taskDiagnose: '종목 진단',
524538
taskDiagnoseDesc: '추세, 거래량, 레벨, 리스크',
@@ -893,9 +907,11 @@ export default {
893907
},
894908
async sendMessageStream(payload, pendingMsg) {
895909
let hasContent = false
910+
let streamAccepted = false
896911
try {
897-
await aiChatApi.streamMessage(payload, async (event, data) => {
898-
if (event === 'meta') {
912+
const streamResult = await aiChatApi.streamMessage(payload, async (event, data) => {
913+
if (event === 'accepted' || event === 'meta') {
914+
streamAccepted = true
899915
this.sessionId = data?.session_id || this.sessionId
900916
return
901917
}
@@ -909,7 +925,29 @@ export default {
909925
await this.revealStreamText(pendingMsg, text)
910926
return
911927
}
928+
if (event === 'replace') {
929+
const text = this.extractStreamText(data)
930+
if (!text) throw new Error(this.text.generateFailed)
931+
hasContent = true
932+
this.updatePendingMessage(pendingMsg, {
933+
content: text,
934+
loading: false,
935+
streamWarning: ''
936+
})
937+
return
938+
}
939+
if (event === 'warning') {
940+
streamAccepted = true
941+
this.updatePendingMessage(pendingMsg, {
942+
loading: false,
943+
streamWarning: data?.code === 'output_limit'
944+
? this.text.outputLimit
945+
: (data?.msg || this.text.streamIncomplete)
946+
})
947+
return
948+
}
912949
if (event === 'done') {
950+
streamAccepted = true
913951
this.sessionId = data?.session_id || this.sessionId
914952
this.updatePendingMessage(pendingMsg, { id: data?.message_id || pendingMsg.id })
915953
const finalText = this.extractStreamText(data)
@@ -924,12 +962,19 @@ export default {
924962
return
925963
}
926964
if (event === 'error') {
927-
throw new Error(data?.msg || data?.message || this.text.generateFailed)
965+
streamAccepted = true
966+
const streamError = new Error(data?.msg || data?.message || this.text.generateFailed)
967+
streamError.streamErrorType = data?.error_type || ''
968+
throw streamError
928969
}
929970
})
971+
if (!streamResult?.completed) {
972+
throw new Error(this.text.streamIncomplete)
973+
}
930974
} catch (error) {
931975
if (error && typeof error === 'object') {
932976
error.streamHasContent = hasContent
977+
error.streamAccepted = streamAccepted
933978
}
934979
throw error
935980
}
@@ -942,9 +987,17 @@ export default {
942987
await this.sendMessageStream(payload, pendingMsg)
943988
return
944989
} catch (error) {
945-
if (error?.streamHasContent) {
946-
this.updatePendingMessage(pendingMsg, { loading: false })
947-
showToast({ message: this.text.streamInterrupted, type: 'fail' })
990+
if (error?.streamAccepted || error?.streamHasContent) {
991+
const hasContent = Boolean(String(pendingMsg.content || '').trim()) && pendingMsg.content !== this.text.sending
992+
this.updatePendingMessage(pendingMsg, {
993+
content: hasContent ? pendingMsg.content : (error?.message || this.text.generateFailed),
994+
loading: false,
995+
streamWarning: hasContent ? this.text.streamInterrupted : ''
996+
})
997+
showToast({
998+
message: hasContent ? this.text.streamInterrupted : (error?.message || this.text.generateFailed),
999+
type: 'fail'
1000+
})
9481001
return
9491002
}
9501003
this.updatePendingMessage(pendingMsg, {
@@ -2008,6 +2061,20 @@ export default {
20082061
line-height: 1.72;
20092062
}
20102063
2064+
.stream-warning {
2065+
display: flex;
2066+
align-items: flex-start;
2067+
gap: 6px;
2068+
margin-top: 10px;
2069+
padding: 8px 10px;
2070+
border: 1px solid rgba(251, 191, 36, 0.28);
2071+
border-radius: 10px;
2072+
color: #fbbf24;
2073+
background: rgba(251, 191, 36, 0.08);
2074+
font-size: 12px;
2075+
line-height: 1.45;
2076+
}
2077+
20112078
.markdown-body :deep(p) {
20122079
margin: 0 0 10px;
20132080
}

tests/unit/mobile-usage-boundary.test.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,10 @@ test('AI composer stays at the bottom and uses a text send action', () => {
206206
assert.match(api, /sendMessage: \(payload\) => http\.post\('\/api\/ai\/chat\/message', payload, \{ timeout: 600000 \}\)/)
207207
assert.match(api, /streamMessage:[\s\S]*\/api\/ai\/chat\/message\/stream/)
208208
assert.match(api, /if \(await handlePart\(part\) === 'done'\)[\s\S]*await reader\.cancel\(\)/)
209-
assert.match(aiHub, /error\.streamHasContent = hasContent[\s\S]*if \(error\?\.streamHasContent\)[\s\S]*return/)
209+
assert.match(aiHub, /if \(!streamResult\?\.completed\)[\s\S]*throw new Error\(this\.text\.streamIncomplete\)/)
210+
assert.match(aiHub, /error\.streamAccepted = streamAccepted[\s\S]*if \(error\?\.streamAccepted \|\| error\?\.streamHasContent\)[\s\S]*return/)
211+
assert.match(aiHub, /event === 'replace'[\s\S]*content: text/)
212+
assert.match(aiHub, /event === 'warning'[\s\S]*streamWarning/)
210213
})
211214

212215
test('signal chart supports mobile history navigation and candle inspection', () => {

0 commit comments

Comments
 (0)