mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): clarify running and stopped states
This commit is contained in:
parent
1159dcdbf3
commit
aecc619bef
@ -205,7 +205,7 @@
|
||||
class="action-btn send-btn"
|
||||
:class="sendBtnClass"
|
||||
:disabled="props.streamPhase === 'interrupting' || (!canSend && !loading)"
|
||||
:title="props.streamPhase === 'interrupting' ? t('chat.streamInterrupting') : undefined"
|
||||
:title="sendButtonTitle"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<!-- 有输入时始终显示发送图标(运行中发送 = interrupt) -->
|
||||
@ -222,7 +222,10 @@
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="input-footer">
|
||||
<span class="input-hint">{{ hint }}</span>
|
||||
<span class="input-footer__left">
|
||||
<span class="input-hint">{{ hint }}</span>
|
||||
<span v-if="runtimeActionHint" class="input-action-hint">{{ runtimeActionHint }}</span>
|
||||
</span>
|
||||
<span v-if="maxLength" class="input-length">
|
||||
{{ inputValue.length }}/{{ maxLength }}
|
||||
</span>
|
||||
@ -499,6 +502,17 @@ const inputPlaceholder = computed(() => {
|
||||
return props.placeholder
|
||||
})
|
||||
|
||||
const runtimeActionHint = computed(() => {
|
||||
if (props.streamPhase === 'interrupting') return t('chat.streamInterrupting')
|
||||
if (!props.loading) return ''
|
||||
if (props.queuedMessage && !canSend.value) return t('chat.streamQueuedWaitingAction')
|
||||
if (props.queuedMessage && canSend.value) return t('chat.streamReplaceQueuedAction')
|
||||
if (canSend.value) return t('chat.streamQueueAction')
|
||||
return t('chat.streamStopAction')
|
||||
})
|
||||
|
||||
const sendButtonTitle = computed(() => runtimeActionHint.value || undefined)
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = () => {
|
||||
if (props.streamPhase === 'interrupting') return
|
||||
@ -887,16 +901,41 @@ defineExpose({
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.input-footer__left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-action-hint {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--mc-border, #e2e8f0);
|
||||
color: var(--mc-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-length {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
@ -197,12 +197,6 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 停止指示器 -->
|
||||
<div v-if="status === 'stopped' || status === 'interrupted'" class="stopped-indicator">
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
<span>{{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- parse_error content block -->
|
||||
<div v-if="parseErrorText" class="parse-error-card">
|
||||
<el-icon class="parse-error-card__icon"><WarningFilled /></el-icon>
|
||||
@ -228,6 +222,18 @@
|
||||
|
||||
</template><!-- /传统合并渲染模式 -->
|
||||
|
||||
<!-- Stopped/interrupted status lives outside the rendering fork so
|
||||
segmented history turns with only thinking/tool output still make
|
||||
the manual stop visible. -->
|
||||
<div
|
||||
v-if="status === 'stopped' || status === 'interrupted'"
|
||||
class="stopped-indicator"
|
||||
:class="status === 'interrupted' ? 'stopped-indicator--interrupted' : 'stopped-indicator--stopped'"
|
||||
>
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
<span>{{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }}</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
INCOMPLETE banner: graph emitted finishReason=incomplete after
|
||||
the thinking-only soft cap stopped the stream.
|
||||
@ -2256,12 +2262,30 @@ watch(isGenerating, (generating) => {
|
||||
|
||||
/* 状态指示器 */
|
||||
.stopped-indicator {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 0 2px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--mc-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-sunken, #f8fafc);
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--mc-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.stopped-indicator--stopped {
|
||||
border-color: color-mix(in srgb, var(--mc-danger, #dc2626) 24%, transparent);
|
||||
background: color-mix(in srgb, var(--mc-danger, #dc2626) 8%, transparent);
|
||||
color: var(--mc-danger, #dc2626);
|
||||
}
|
||||
|
||||
.stopped-indicator--interrupted {
|
||||
border-color: color-mix(in srgb, #d97706 26%, transparent);
|
||||
background: color-mix(in srgb, #d97706 8%, transparent);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* 错误卡片 */
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ChatInput from '../ChatInput.vue'
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = []
|
||||
|
||||
const messages = {
|
||||
chat: {
|
||||
messagePlaceholder: 'Type a message',
|
||||
streamStopAction: 'Stop generation',
|
||||
streamQueueAction: 'Send after current response',
|
||||
streamReplaceQueuedAction: 'Replace queued message',
|
||||
streamQueuedWaitingAction: 'Queued message waiting for current response',
|
||||
streamInterrupting: 'Interrupting...',
|
||||
queuedSending: 'Sending queued message...',
|
||||
queuedWillSend: 'Queued, will send after current step',
|
||||
queuedCancel: 'Cancel',
|
||||
queuedReplace: 'Message queued. Press Enter to replace...',
|
||||
thinkingOn: 'Deep thinking enabled',
|
||||
thinkingOff: 'Click to enable deep thinking',
|
||||
thinkingUnsupported: 'Current model does not support deep thinking',
|
||||
},
|
||||
}
|
||||
|
||||
function mountChatInput(props: Record<string, unknown>) {
|
||||
const events: string[] = []
|
||||
const payloads: unknown[] = []
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp(ChatInput, {
|
||||
placeholder: messages.chat.messagePlaceholder,
|
||||
...props,
|
||||
onSubmit: (value: string) => {
|
||||
events.push('submit')
|
||||
payloads.push(value)
|
||||
},
|
||||
onStop: () => {
|
||||
events.push('stop')
|
||||
},
|
||||
})
|
||||
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: messages } }))
|
||||
app.mount(host)
|
||||
apps.push(app)
|
||||
return { host, events, payloads }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
apps.splice(0).forEach(app => app.unmount())
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ChatInput running-state actions', () => {
|
||||
it('labels and emits stop when running with empty input', () => {
|
||||
const { host, events } = mountChatInput({ loading: true, modelValue: '' })
|
||||
const send = host.querySelector<HTMLButtonElement>('.send-btn')
|
||||
|
||||
expect(host.textContent).toContain('Stop generation')
|
||||
expect(send?.title).toBe('Stop generation')
|
||||
send?.click()
|
||||
|
||||
expect(events).toEqual(['stop'])
|
||||
})
|
||||
|
||||
it('labels and submits queued content when running with text', () => {
|
||||
const { host, events, payloads } = mountChatInput({ loading: true, modelValue: 'next task' })
|
||||
const send = host.querySelector<HTMLButtonElement>('.send-btn')
|
||||
|
||||
expect(host.textContent).toContain('Send after current response')
|
||||
expect(send?.title).toBe('Send after current response')
|
||||
send?.click()
|
||||
|
||||
expect(events).toEqual(['submit'])
|
||||
expect(payloads).toEqual(['next task'])
|
||||
})
|
||||
|
||||
it('labels queued waiting state and does not stop on an empty repeated submit', () => {
|
||||
const { host, events } = mountChatInput({
|
||||
loading: true,
|
||||
modelValue: '',
|
||||
queuedMessage: { id: 'q1', text: 'queued task', status: 'sending' },
|
||||
queueSize: 1,
|
||||
})
|
||||
const send = host.querySelector<HTMLButtonElement>('.send-btn')
|
||||
|
||||
expect(host.textContent).toContain('Queued message waiting for current response')
|
||||
expect(send?.title).toBe('Queued message waiting for current response')
|
||||
send?.click()
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('labels replacement when running with queued content and new text', () => {
|
||||
const { host, events, payloads } = mountChatInput({
|
||||
loading: true,
|
||||
modelValue: 'replacement task',
|
||||
queuedMessage: { id: 'q1', text: 'queued task', status: 'queued' },
|
||||
queueSize: 1,
|
||||
})
|
||||
const send = host.querySelector<HTMLButtonElement>('.send-btn')
|
||||
|
||||
expect(host.textContent).toContain('Replace queued message')
|
||||
expect(send?.title).toBe('Replace queued message')
|
||||
send?.click()
|
||||
|
||||
expect(events).toEqual(['submit'])
|
||||
expect(payloads).toEqual(['replacement task'])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,132 @@
|
||||
import { createApp, defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '@/types'
|
||||
|
||||
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
||||
useStreamingMarkdown: (content: unknown) => ({
|
||||
renderedContent: content,
|
||||
copyCode: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/composables/useAuthenticatedAttachment', () => ({
|
||||
useAuthenticatedAttachment: () => ({
|
||||
blobUrls: {},
|
||||
loadAllImages: vi.fn(),
|
||||
loadAllVideos: vi.fn(),
|
||||
loadAllAudios: vi.fn(),
|
||||
loadAllModels: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
openImage: vi.fn(),
|
||||
getDisplayUrl: vi.fn((url: string) => url),
|
||||
revokeAll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/composables/useMcToast', () => ({
|
||||
mcToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/composables/useToolLabel', () => ({
|
||||
useToolLabel: () => ({ getToolLabel: (name: string) => name }),
|
||||
}))
|
||||
vi.mock('@/api', () => ({ http: { get: vi.fn(), post: vi.fn() } }))
|
||||
vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn() }))
|
||||
vi.mock('@/utils/generatedFileLinks', () => ({
|
||||
buildGeneratedFileNameMap: vi.fn(() => new Map()),
|
||||
linkifyGeneratedFileUrls: vi.fn((content: string) => content),
|
||||
}))
|
||||
vi.mock('@/utils/lazyModelViewer', () => ({ ensureModelViewer: vi.fn() }))
|
||||
vi.mock('../preview/previewKind', () => ({ previewKindOf: vi.fn(() => null) }))
|
||||
vi.mock('../preview/previewBus', () => ({ openFilePreview: vi.fn() }))
|
||||
vi.mock('@/components/goal/GoalAvatarRing.vue', () => ({
|
||||
default: defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }),
|
||||
}))
|
||||
vi.mock('../ToolCallSegment.vue', () => ({
|
||||
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.toolName) }),
|
||||
}))
|
||||
vi.mock('../ThinkingSegment.vue', () => ({
|
||||
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.thinkingText) }),
|
||||
}))
|
||||
vi.mock('../ContentSegment.vue', () => ({
|
||||
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.text) }),
|
||||
}))
|
||||
vi.mock('../PlanStepsPanel.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
|
||||
vi.mock('../BrowserTimeline.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
|
||||
vi.mock('../TypingCursor.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
|
||||
vi.mock('../UserMessageContent.vue', () => ({
|
||||
default: defineComponent({ props: ['content'], setup: props => () => h('div', props.content) }),
|
||||
}))
|
||||
|
||||
import MessageBubble from '../MessageBubble.vue'
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = []
|
||||
|
||||
function mountMessage(message: Message, locale = 'zh-CN') {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp(MessageBubble, { message })
|
||||
app.use(createPinia())
|
||||
app.use(createI18n({
|
||||
legacy: false,
|
||||
locale,
|
||||
messages: {
|
||||
'zh-CN': {
|
||||
chat: {
|
||||
stopped: '已被用户手动中止',
|
||||
interrupted: '已中断并继续处理下一条消息',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
chat: {
|
||||
stopped: 'Stopped manually by user',
|
||||
interrupted: 'Interrupted and continued with the next message',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
app.mount(host)
|
||||
apps.push(app)
|
||||
return host
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
apps.splice(0).forEach(app => app.unmount())
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('MessageBubble stop indicator', () => {
|
||||
it('shows a manual-stop status bar for segmented stopped assistant messages', () => {
|
||||
const host = mountMessage({
|
||||
id: '2',
|
||||
conversationId: 'conv',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
contentParts: [],
|
||||
status: 'stopped',
|
||||
metadata: {
|
||||
segments: [
|
||||
{ id: 'think-1', type: 'thinking', status: 'completed', thinkingText: '分析中', seq: 0 },
|
||||
{ id: 'tool-1', type: 'tool_call', status: 'completed', toolName: 'shell', seq: 1 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(host.querySelector('.segments-view')).not.toBeNull()
|
||||
expect(host.textContent).toContain('已被用户手动中止')
|
||||
expect(host.querySelector('.stopped-indicator--stopped')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('distinguishes interrupted turns from user-stopped turns', () => {
|
||||
const host = mountMessage({
|
||||
id: '3',
|
||||
conversationId: 'conv',
|
||||
role: 'assistant',
|
||||
content: 'partial',
|
||||
contentParts: [],
|
||||
status: 'interrupted',
|
||||
})
|
||||
|
||||
expect(host.textContent).toContain('已中断并继续处理下一条消息')
|
||||
expect(host.querySelector('.stopped-indicator--interrupted')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@ -256,8 +256,8 @@ export default {
|
||||
resumed: 'Resumed',
|
||||
processing: 'Processing',
|
||||
},
|
||||
stopped: 'Generation stopped',
|
||||
interrupted: 'Interrupted',
|
||||
stopped: 'Stopped manually by user',
|
||||
interrupted: 'Interrupted and continued with the next message',
|
||||
subagentStalled: 'Subagent stalled — no progress',
|
||||
subagentAsync: 'Running in background — result via task_output',
|
||||
executionPlan: 'Execution Plan',
|
||||
@ -548,6 +548,10 @@ export default {
|
||||
queuedReplace: 'Message queued. Press Enter to replace...',
|
||||
queuedBadge: '{count} queued',
|
||||
// Stream status
|
||||
streamStopAction: 'Stop generation',
|
||||
streamQueueAction: 'Send after current response',
|
||||
streamReplaceQueuedAction: 'Replace queued message',
|
||||
streamQueuedWaitingAction: 'Queued message waiting for current response',
|
||||
streamPreparingContext: 'Preparing context...',
|
||||
streamReadingMemory: 'Reading memory...',
|
||||
streamReasoning: 'Analyzing...',
|
||||
|
||||
@ -256,8 +256,8 @@ export default {
|
||||
resumed: '已恢复',
|
||||
processing: '处理中',
|
||||
},
|
||||
stopped: '已停止生成',
|
||||
interrupted: '已中断',
|
||||
stopped: '已被用户手动中止',
|
||||
interrupted: '已中断并继续处理下一条消息',
|
||||
subagentStalled: '子 Agent 无进展',
|
||||
subagentAsync: '后台运行中,结果稍后获取',
|
||||
executionPlan: '执行计划',
|
||||
@ -548,6 +548,10 @@ export default {
|
||||
queuedReplace: '消息已排队,按回车替换...',
|
||||
queuedBadge: '{count} 条排队',
|
||||
// 流状态
|
||||
streamStopAction: '停止生成',
|
||||
streamQueueAction: '当前回复结束后发送',
|
||||
streamReplaceQueuedAction: '替换已排队消息',
|
||||
streamQueuedWaitingAction: '已排队,等待当前回复结束',
|
||||
streamPreparingContext: '准备上下文...',
|
||||
streamReadingMemory: '读取记忆...',
|
||||
streamReasoning: '分析问题...',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user