Repository navigation
feat: chat speak - #7793
feat: chat speak#7793
Conversation
| <ButtonFeedback v-if="canFeedback" :current-status="currentStatus" :submitting="submitting" @select="openFeedback" /> | ||
| <span v-if="meta.createTime" class="text-N600 ml-1">{{ datetimeFormat(meta.createTime) }}</span> | ||
| </div> | ||
| <el-collapse-transition> |
There was a problem hiding this comment.
Here is an analysis of the provided code, highlighting potential issues, irregularities, and optimization suggestions.
1. Security & Privacy Concerns
- XSS via Dynamic Icon Names:
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. - Content Injection in
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
- Redundant
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.- Fix: Add a guard in
stopReading:function stopReading() { if (!speechSupported) return; // Guard against SSR or unsupported env if (utterance) { // ... cancel logic } speaking.value = false; }
- Fix: Add a guard in
3. Bug Risks & Logic Flaws
-
Race Condition in
toggleReading:utterance.onend = utterance.onerror = () => { speaking.value = false; utterance = undefined; }; speaking.value = true; window.speechSynthesis.speak(utterance);
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.- Optimization: Check if
speaking.valueis already changing in the callback? - Better Pattern: Use a unique ID for each utterance to ignore stale callbacks.
- Optimization: Check if
-
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:
IfspeechSynthesisfails 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
- Vue Composition API Best Practices:
- ✅ Good: Using
computedfor derived state. - ✅ Good: Cleanup via
onBeforeUnmount. ⚠️ Suggestion: Defineutterancetype more strictly. Currentlylet utterance: SpeechSynthesisUtterance | undefined. This is fine, but consider wrapping speech logic in a Composable (e.g.,useSpeechSynthesis) for reusability across components.
- ✅ Good: Using
- Template Optimization:
- You moved
<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. - ✅ Good: Conditional rendering with
v-ifforshowShareandcanFeedbackavoids unnecessary DOM nodes.
- You moved
5. Specific Suggestions for Improvement
A. Refactor Speech Logic into a Composable
Encapsulate speaking, utterance, stopReading, and toggleReading into a reusable hook to avoid cluttering the main component.
// composable.ts
export function useSpeech(contentRef: Ref<string>) {
const supported = typeof window !== 'undefined' && 'speechSynthesis' in window;
const speaking = ref(false);
let currentUtterance: SpeechSynthesisUtterance | null = null;
const stop = () => {
if (supported && currentUtterance) {
currentUtterance.onend = null;
currentUtterance.onerror = null;
window.speechSynthesis.cancel();
currentUtterance = null;
}
speaking.value = false;
};
const toggle = () => {
if (speaking.value) return stop();
if (!supported || !contentRef.value) return;
window.speechSynthesis.cancel(); // Clear queue
const utt = new SpeechSynthesisUtterance(contentRef.value);
utt.onend = () => { speaking.value = false; currentUtterance = null; };
utt.onerror = () => {
speaking.value = false;
currentUtterance = null;
// Optionally handle error logging here
};
currentUtterance = utt;
speaking.value = true;
window.speechSynthesis.speak(utt);
};
onBeforeUnmount(stop);
return { supported, speaking, toggle };
}B. Improve canRegenerate Logic
Ensure messageList is defined before accessing its properties to avoid runtime errors if props.message is passed without context. Although guarded by &&, TypeScript might complain.
C. Accessibility (a11y)
- Add
aria-labelto buttons if tooltips are not sufficient for screen readers. - Ensure
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.
| regenerate, | ||
| uploadFile, | ||
| onStreamProgress, | ||
| stop, |
There was a problem hiding this comment.
The patch is syntactically valid, but there are several correctness/data-loss risks and some cleanup suggestions.
Main issues
-
reChatcan clear the wrong messageconst target = opts.newQuestion ? messages.value[messages.value.length - 1] : messages.value.find(...) || messages.value[messages.value.length - 1]
For
reChat, if the record cannot be found, this falls back to the last message. The first stream chunk then clears that message’s content. That is dangerous.- For
opts.reChat, do not fall back to the last message; return if no matching target is found.
- For
-
Target lookup may match a non-assistant message
(!!opts.chatRecordId && message.recordId === opts.chatRecordId) || message.id === `${opts.chatRecordId}_ASSISTANT`
The
recordIdbranch does not verifymessage.role === 'ASSISTANT'. If any non-assistant message has the samerecordId, you could clear the wrong message.- Add
message.role === 'ASSISTANT'. - Also guard the legacy-ID branch when
opts.chatRecordIdis missing, to avoid matching"undefined_ASSISTANT".
- Add
-
regeneratedoes not verify the target is an assistant messageif (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
If
regenerateis called on aUSERmessage with arecordId, and the previous message is alsoUSER, this can pass and send the wrong request.- Add
if (message.role !== 'ASSISTANT') return. - Use
question?.content?.[0]to avoid a runtime error ifcontentis missing.
- Add
-
recordIdmay not be updated on reChatif (chunk?.chat_record_id && !target.recordId) target.recordId = chunk.chat_record_id
During a reChat,
target.recordIdis usually already set, so a newchat_record_idfrom the backend will be ignored. If reChat can return a new record ID, this leaves a stale ID.- Decide explicitly whether reChat should update it, e.g.:
— but only if that matches the backend semantics.
if (chunk?.chat_record_id && (!target.recordId || opts.reChat)) { target.recordId = chunk.chat_record_id }
- Decide explicitly whether reChat should update it, e.g.:
-
Old content is cleared on the first chunk, even if that chunk has no content
if (opts.reChat && !replacementStarted) { target.content = [] target.record = undefined target.write_ed = false replacementStarted = true }
If the first chunk is only metadata, a ping, an error, or an empty delta, the old answer is cleared before any replacement content arrives.
- Clear only when the first actual content-bearing chunk arrives.
- Consider preserving the old content and restoring it if the stream fails before meaningful content is received.
-
State reset is incomplete
You resetcontent,record, andwrite_ed, but other streaming/error state may remain stale, e.g.error,status,finished, references, etc. Audit theChatMessageshape and reset all fields that can affect rendering. -
regenerateignores the result ofsendMessage
IfsendMessagereturns aPromise, callers ofregeneratecannot await it or catch errors.- Return it:
return sendMessage(...)
- Or make
regenerateasyncandawaitit.
- Return it:
-
indexOf(message)relies on object identity
If the caller ever passes a cloned or reconstructed message object,indexOfreturns-1. If that is possible, use an ID-based lookup, e.g.findIndex(m => m.id === message.id). -
Potential retry/duplicate issue
replacementStartedis local to onesendMessagecall. IfrunStreaminternally retries after a partial response, the content will not be cleared again and chunks may be appended to a partial answer. Check whetherrunStreamsupports retries.
Minor suggestions
- Simplify and guard the target lookup:
const assistantId = opts.chatRecordId ? `${opts.chatRecordId}_ASSISTANT` : undefined const matched = opts.chatRecordId ? messages.value.find( (m) => m.role === 'ASSISTANT' && (m.recordId === opts.chatRecordId || m.id === assistantId), ) : undefined const target = opts.newQuestion ? messages.value[messages.value.length - 1] : matched ?? (!opts.reChat ? messages.value[messages.value.length - 1] : undefined)
- If
recordIdis not guaranteed unique, passmessage.idor the index tosendMessageso the exact message can be targeted. - Consider naming it
regenerateMessagefor clarity if the store exports many actions.
Overall: the main fixes should be to prevent reChat from falling back to the last message, ensure the target is an assistant message, and clear old content only when actual replacement content arrives.
| max-width: calc(800px + var(--spacing) * 8); | ||
| width: 100%; | ||
| } | ||
|
|
There was a problem hiding this comment.
The diff is syntactically valid CSS, but there are a few things to verify:
-
--spacingmust be defined and must resolve to a length- If
--spacingis undefined,calc(800px + var(--spacing) * 8)becomes invalid at computed-value time. - In that case,
max-widthfalls back tonone, and becausewidth: 100%is set, the element can expand to the full parent width. That is a likely layout regression. - Safer:
max-width: calc(800px + (var(--spacing, 0px) * 8));
- If
-
box-sizing: border-boxchanges the width calculation- Before,
max-width: 800pxlikely behaved as content-box unless a global reset already setborder-box. - With
border-box, padding and borders are included in the800px. Increasing the max-width may be compensating for that, but only if the horizontal padding/border is exactlyvar(--spacing) * 8. - If the goal is to preserve an 800px content area, it is clearer to account for actual padding/border:
or keep a fixed max-width and let
max-width: calc(800px + var(--padding-inline) * 2);
border-boxinclude padding.
- Before,
-
Check whether
--spacingis a length, not a unitless number- This is valid if
--spacingis something like4pxor0.25rem. - This is invalid if
--spacingis4with no unit. - Also consider whether using a relative unit such as
remis intentional, since the max width will then change with font size.
- This is valid if
-
Browser support for
calc()multiplication- Modern browsers support
calc(var(--spacing) * 8). - If you need to support very old browsers, precompute the value or avoid multiplication in
calc().
- Modern browsers support
-
Readability / line length
max-widthcontrols line length. Increasing it from800pxto800px + spacing * 8may reduce readability if this element contains long-form conversation text.
Suggested safer version, assuming --spacing is a length:
.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 --spacing token’s scope/type, confirm the border-box + increased max-width combination is intentional, and add a fallback to avoid an unintended full-width layout.
feat: chat speak