Repository navigation
feat: chat speak #7793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: chat speak #7793
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -169,7 +169,9 @@ export function createMessageListStore(deps: MessageListDeps) { | |
| } | ||
| const target = opts.newQuestion | ||
| ? messages.value[messages.value.length - 1] | ||
| : messages.value.find((message) => message.id === `${opts.chatRecordId}_ASSISTANT`) || messages.value[messages.value.length - 1] | ||
| : messages.value.find( | ||
| (message) => (!!opts.chatRecordId && message.recordId === opts.chatRecordId) || message.id === `${opts.chatRecordId}_ASSISTANT`, | ||
| ) || messages.value[messages.value.length - 1] | ||
| if (!target) return | ||
|
|
||
| const payload: Dict<unknown> = { message: opts.message, stream: true, re_chat: opts.reChat ?? false } | ||
|
|
@@ -178,10 +180,17 @@ export function createMessageListStore(deps: MessageListDeps) { | |
| if (opts.chatRecordId) payload.chat_record_id = opts.chatRecordId | ||
| if (opts.chunkId) payload.chunk_id = opts.chunkId | ||
|
|
||
| let replacementStarted = false | ||
| runStream( | ||
| () => chatMessage(cid, payload), | ||
| (chunk) => { | ||
| if (chunk?.chat_record_id && !target.recordId) target.recordId = chunk.chat_record_id | ||
| if (opts.reChat && !replacementStarted) { | ||
| target.content = [] | ||
| target.record = undefined | ||
| target.write_ed = false | ||
| replacementStarted = true | ||
| } | ||
| appendChunk(target, chunk) | ||
| opts.onNext?.(chunk) | ||
| }, | ||
|
|
@@ -193,6 +202,15 @@ export function createMessageListStore(deps: MessageListDeps) { | |
| ) | ||
| } | ||
|
|
||
| // 原回答保留到重新生成收到首个分片,避免请求失败时丢失已展示内容。 | ||
| const regenerate = (message: ChatMessage) => { | ||
| if (streamLoading.value || loading.value || !message.recordId) return | ||
| const index = messages.value.indexOf(message) | ||
| const question = messages.value[index - 1] | ||
| if (index < 1 || question?.role !== 'USER' || !question.content[0]) return | ||
| sendMessage({ message: question.content[0], newQuestion: false, reChat: true, chatRecordId: message.recordId }) | ||
| } | ||
|
|
||
| // 优雅停止:仅通知后端取消,本地流继续读到 [DONE] 自行结束 | ||
| const stop = () => { | ||
| const cid = getChatId() | ||
|
|
@@ -245,6 +263,7 @@ export function createMessageListStore(deps: MessageListDeps) { | |
| load, | ||
| loadMore, | ||
| sendMessage, | ||
| regenerate, | ||
| uploadFile, | ||
| onStreamProgress, | ||
| stop, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The patch is syntactically valid, but there are several correctness/data-loss risks and some cleanup suggestions. Main issues
Minor suggestions
Overall: the main fixes should be to prevent |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,8 +15,9 @@ | |
|
|
||
| /* 对话正文:消息列表与底部输入区共用宽度和居中布局。 */ | ||
| .mk-conversation-content { | ||
| box-sizing: border-box; | ||
| margin-inline: auto; | ||
| max-width: 800px; | ||
| max-width: calc(800px + var(--spacing) * 8); | ||
| width: 100%; | ||
| } | ||
|
|
||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The diff is syntactically valid CSS, but there are a few things to verify:
Suggested safer version, assuming .mk-conversation-content {
box-sizing: border-box;
margin-inline: auto;
max-width: calc(800px + (var(--spacing, 0px) * 8));
width: 100%;
}If this width is reused elsewhere, consider storing it in a custom property: .mk-conversation-content {
--conversation-max-width: calc(800px + (var(--spacing, 0px) * 8));
box-sizing: border-box;
margin-inline: auto;
max-width: var(--conversation-max-width);
width: 100%;
}Main action items: verify the |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here is an analysis of the provided code, highlighting potential issues, irregularities, and optimization suggestions.
1. Security & Privacy Concerns
icon_pause-round_outlinedandicon_speaker_outlined are hardcoded, which is safe. However, ensure that if dynamic names were ever used, they are sanitized. In this specific snippet,namebindings are static or derived from simple booleans (speaking ? ... : ...`), so XSS risk from icon names is low.answerText:window.speechSynthesis.speak(utterance)usesanswerText.value. IfcollectAnswerTextreturns raw HTML or unescaped content, it could lead to injection issues if the speech synthesis backend or underlying browser implementation mishandles the string. WhileSpeechSynthesisUtterancetreats input as plain text, ensurecollectAnswerTextdoes not expose sensitive data unintentionally.2. Performance & Reactivity Issues
computedEvaluation:answerTextis a computed property. It will re-evaluate every time any reactive dependency changes. Ifprops.message.contentis a large object,collectAnswerTextmight be expensive. Consider memoizing or optimizingcollectAnswerTextif it performs complex parsing.onBeforeUnmountLeak Risk:speechSupportedis checked insidestopReading(), butonBeforeUnmount(stopReading)relies onwindow.speechSynthesisexisting. If the component unmounts beforewindowis properly defined (e.g., during initial SSR hydration or race conditions),stopReadingwill throw an error becausewindow.speechSynthesiswould be undefined.stopReading:3. Bug Risks & Logic Flaws
Race Condition in
toggleReading:If
speechSynthesis.speak()triggersonend/onerrorasynchronously very quickly (e.g., if the text is empty or error occurs immediately),speaking.valueis set tofalse, bututteranceis also set toundefined. However, if a user clicks "Stop" (toggleReading) whilespeakingistrue,stopReading()cancels synthesis. The subsequentonendcallback might fire afterstopReadingalready clearedutteranceand setspeakingtofalse. This is generally handled by checkingif (!speechSupported || !answerText.value) returnat the start, but edge cases with rapid toggling may cause state desync.speaking.valueis already changing in the callback?regenerateAnswerState Conflict:You have two separate loading states:
messageList.streamLoading.valueandmessageList.loading.value. Ensure these are mutually exclusive or thatcanRegeneratecorrectly accounts for all pending operations. IfstreamLoadingis true,regenerateis disabled, which is good. But after regeneration starts, doesloadingbecome true? Ensure the UI reflects this transition smoothly.Missing Error Handling for Synthesis:
If
speechSynthesisfails mid-speak (e.g., system error),onerroris called, but no user feedback (toast/error message) is provided. The UI just stops speaking silently. Consider adding a toast notification if speech fails unexpectedly.4. Code Style & Best Practices
computedfor derived state.onBeforeUnmount.utterancetype more strictly. Currentlylet utterance: SpeechSynthesisUtterance | undefined. This is fine, but consider wrapping speech logic in a Composable (e.g.,useSpeechSynthesis) for reusability across components.<ButtonFeedback>up in the template. Ensure the visual layout remains consistent. Moving elements in DOM without adjusting CSS flex/grid order might cause unexpected shifts.v-ifforshowShareandcanFeedbackavoids unnecessary DOM nodes.5. Specific Suggestions for Improvement
A. Refactor Speech Logic into a Composable
Encapsulate
speaking,utterance,stopReading, andtoggleReadinginto a reusable hook to avoid cluttering the main component.B. Improve
canRegenerateLogicEnsure
messageListis defined before accessing its properties to avoid runtime errors ifprops.messageis passed without context. Although guarded by&&, TypeScript might complain.C. Accessibility (a11y)
aria-labelto buttons if tooltips are not sufficient for screen readers.aria-pressed="true"on the speak button whenspeakingis true.Final Verdict
The code is structurally sound and follows Vue 3 Composition API patterns. The main improvements lie in extracting speech logic into a composable, adding guards for SSR/environment checks in cleanup, and providing better error feedback for TTS failures.