mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
fix: merge streamed tail text into the previous assistant bubble
Keep late-arriving streamed punctuation attached to the active assistant reply so desktop chat no longer renders the final character as its own bubble. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5a86ab097f
commit
144182db63
@ -385,6 +385,95 @@ describe('chatStore history mapping', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('merges trailing streamed punctuation into the previous assistant bubble on completion', () => {
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {
|
||||||
|
[TEST_SESSION_ID]: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'assistant-1',
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: '你好',
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatState: 'streaming',
|
||||||
|
connectionState: 'connected',
|
||||||
|
streamingText: '。',
|
||||||
|
streamingToolInput: '',
|
||||||
|
activeToolUseId: null,
|
||||||
|
activeToolName: null,
|
||||||
|
activeThinkingId: null,
|
||||||
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
elapsedSeconds: 0,
|
||||||
|
statusVerb: '',
|
||||||
|
slashCommands: [],
|
||||||
|
agentTaskNotifications: {},
|
||||||
|
elapsedTimer: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||||
|
type: 'message_complete',
|
||||||
|
usage: { input_tokens: 1, output_tokens: 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: '你好。',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges streamed tail text when status changes out of streaming', () => {
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {
|
||||||
|
[TEST_SESSION_ID]: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'assistant-1',
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: '分析完成',
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatState: 'streaming',
|
||||||
|
connectionState: 'connected',
|
||||||
|
streamingText: '。',
|
||||||
|
streamingToolInput: '',
|
||||||
|
activeToolUseId: null,
|
||||||
|
activeToolName: null,
|
||||||
|
activeThinkingId: null,
|
||||||
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
elapsedSeconds: 0,
|
||||||
|
statusVerb: '',
|
||||||
|
slashCommands: [],
|
||||||
|
agentTaskNotifications: {},
|
||||||
|
elapsedTimer: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||||
|
type: 'status',
|
||||||
|
state: 'idle',
|
||||||
|
tokens: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: '分析完成。',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('flushes the previous assistant draft before starting a new user turn', () => {
|
it('flushes the previous assistant draft before starting a new user turn', () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@ -108,6 +108,29 @@ const pendingTaskToolUseIds = new Set<string>()
|
|||||||
let msgCounter = 0
|
let msgCounter = 0
|
||||||
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
|
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
|
||||||
|
|
||||||
|
function appendAssistantTextMessage(messages: UIMessage[], content: string): UIMessage[] {
|
||||||
|
if (!content) return messages
|
||||||
|
const last = messages[messages.length - 1]
|
||||||
|
if (last?.type === 'assistant_text') {
|
||||||
|
return [
|
||||||
|
...messages.slice(0, -1),
|
||||||
|
{
|
||||||
|
...last,
|
||||||
|
content: last.content + content,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
id: nextId(),
|
||||||
|
type: 'assistant_text',
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// Streaming throttle for content_delta
|
// Streaming throttle for content_delta
|
||||||
let pendingDelta = ''
|
let pendingDelta = ''
|
||||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@ -218,15 +241,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
const pendingAssistantText = `${session.streamingText}${bufferedDelta}`.trim()
|
const pendingAssistantText = `${session.streamingText}${bufferedDelta}`.trim()
|
||||||
|
|
||||||
const newMessages = pendingAssistantText
|
const newMessages = pendingAssistantText
|
||||||
? [
|
? appendAssistantTextMessage(session.messages, pendingAssistantText)
|
||||||
...session.messages,
|
|
||||||
{
|
|
||||||
id: nextId(),
|
|
||||||
type: 'assistant_text' as const,
|
|
||||||
content: pendingAssistantText,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [...session.messages]
|
: [...session.messages]
|
||||||
if (!isMemberSession && allTasksDone) {
|
if (!isMemberSession && allTasksDone) {
|
||||||
newMessages.push({
|
newMessages.push({
|
||||||
@ -392,6 +407,16 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'status':
|
case 'status':
|
||||||
|
// Flush any pending delta before processing status change
|
||||||
|
if (flushTimer && msg.state !== 'streaming') {
|
||||||
|
clearTimeout(flushTimer)
|
||||||
|
flushTimer = null
|
||||||
|
}
|
||||||
|
if (pendingDelta && msg.state !== 'streaming') {
|
||||||
|
const deltaText = pendingDelta
|
||||||
|
pendingDelta = ''
|
||||||
|
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + deltaText })) }))
|
||||||
|
}
|
||||||
update((session) => {
|
update((session) => {
|
||||||
const pendingText = session.streamingText.trim()
|
const pendingText = session.streamingText.trim()
|
||||||
const shouldFlush = pendingText && session.chatState === 'streaming' && msg.state !== 'streaming'
|
const shouldFlush = pendingText && session.chatState === 'streaming' && msg.state !== 'streaming'
|
||||||
@ -401,7 +426,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
|
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
|
||||||
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
|
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
|
||||||
...(shouldFlush ? {
|
...(shouldFlush ? {
|
||||||
messages: [...session.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
|
messages: appendAssistantTextMessage(session.messages, pendingText),
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
} : {}),
|
} : {}),
|
||||||
}
|
}
|
||||||
@ -423,7 +448,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
const pendingText = session.streamingText.trim()
|
const pendingText = session.streamingText.trim()
|
||||||
if (pendingText) {
|
if (pendingText) {
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
|
messages: appendAssistantTextMessage(s.messages, pendingText),
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -460,7 +485,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
update((s) => {
|
update((s) => {
|
||||||
const pendingText = s.streamingText.trim()
|
const pendingText = s.streamingText.trim()
|
||||||
const base = pendingText
|
const base = pendingText
|
||||||
? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }]
|
? appendAssistantTextMessage(s.messages, pendingText)
|
||||||
: s.messages
|
: s.messages
|
||||||
const last = base[base.length - 1]
|
const last = base[base.length - 1]
|
||||||
if (last && last.type === 'thinking') {
|
if (last && last.type === 'thinking') {
|
||||||
@ -553,13 +578,25 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'message_complete': {
|
case 'message_complete': {
|
||||||
|
// Flush any pending delta before processing complete
|
||||||
|
if (flushTimer) {
|
||||||
|
clearTimeout(flushTimer)
|
||||||
|
flushTimer = null
|
||||||
|
}
|
||||||
|
if (pendingDelta) {
|
||||||
|
const deltaText = pendingDelta
|
||||||
|
pendingDelta = ''
|
||||||
|
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + deltaText })) }))
|
||||||
|
}
|
||||||
const session = get().sessions[sessionId]
|
const session = get().sessions[sessionId]
|
||||||
if (!session) break
|
if (!session) break
|
||||||
const text = session.streamingText
|
const text = session.streamingText.trim()
|
||||||
if (text) {
|
if (text) {
|
||||||
update((s) => ({
|
set((s) => ({
|
||||||
messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
|
sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
|
||||||
streamingText: '',
|
messages: appendAssistantTextMessage(sess.messages, text),
|
||||||
|
streamingText: '',
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||||
@ -579,7 +616,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
const pendingText = s.streamingText.trim()
|
const pendingText = s.streamingText.trim()
|
||||||
const newMessages = [...s.messages]
|
const newMessages = [...s.messages]
|
||||||
if (pendingText) {
|
if (pendingText) {
|
||||||
newMessages.push({ id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() })
|
newMessages.splice(0, newMessages.length, ...appendAssistantTextMessage(newMessages, pendingText))
|
||||||
}
|
}
|
||||||
newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() })
|
newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() })
|
||||||
return {
|
return {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user