feat(stream): add fine-grained phase status hints for frontend UX

This commit is contained in:
matevip 2026-04-07 00:40:38 +08:00
parent d5f1d19306
commit 2ac7cc4af4
10 changed files with 260 additions and 12 deletions

View File

@ -172,7 +172,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size()));
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(), conversationId));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -235,6 +235,14 @@ public class ToolExecutionExecutor {
private void executePreparedCalls(List<PreparedToolCall> preparedCalls,
List<ToolResponseMessage.ToolResponse> allResponses,
List<GraphEventPublisher.GraphEvent> events) {
if (!preparedCalls.isEmpty() && streamTracker != null) {
String conversationId = preparedCalls.get(0).conversationId;
String phase = classifyBatchPhase(preparedCalls);
streamTracker.updatePhase(conversationId, phase);
streamTracker.broadcastObject(conversationId, "phase", GraphEventPublisher.phase(phase, Map.of(
"toolCount", preparedCalls.size()
)).data());
}
// 分组: 连续的 safe 工具可以并行遇到 unsafe 工具则先等待所有 safe 完成再独占执行
List<List<PreparedToolCall>> batches = buildExecutionBatches(preparedCalls);
@ -320,6 +328,11 @@ public class ToolExecutionExecutor {
List<GraphEventPublisher.GraphEvent> events) {
String toolName = pc.toolCall.name();
try {
if (streamTracker != null) {
streamTracker.updateRunningTool(pc.conversationId, toolName);
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
GraphEventPublisher.toolStart(toolName, pc.arguments).data());
}
log.info("[ToolExecutor] Executing tool: {} with args: {}",
toolName, pc.arguments != null && pc.arguments.length() > 200
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
@ -338,12 +351,22 @@ public class ToolExecutionExecutor {
log.info("[ToolExecutor] Tool {} returned {} chars", toolName, rawLen);
}
events.add(GraphEventPublisher.toolComplete(toolName, result, true));
if (streamTracker != null) {
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
GraphEventPublisher.toolComplete(toolName, result, true).data());
streamTracker.updateRunningTool(pc.conversationId, null);
}
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, result != null ? result : "");
} catch (Exception e) {
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
String normalizedError = normalizeToolExecutionError(e);
events.add(GraphEventPublisher.toolComplete(toolName, normalizedError, false));
if (streamTracker != null) {
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
GraphEventPublisher.toolComplete(toolName, normalizedError, false).data());
streamTracker.updateRunningTool(pc.conversationId, null);
}
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, normalizedError);
}
@ -408,6 +431,13 @@ public class ToolExecutionExecutor {
return !DEFAULT_UNSAFE_TOOLS.contains(toolName);
}
private String classifyBatchPhase(List<PreparedToolCall> preparedCalls) {
boolean memoryOnly = preparedCalls.stream().allMatch(pc ->
"read_workspace_memory_file".equals(pc.toolCall.name())
|| "list_workspace_memory_files".equals(pc.toolCall.name()));
return memoryOnly ? "reading_memory" : "executing_tool";
}
private String normalizeToolExecutionError(Exception e) {
String message = e != null && e.getMessage() != null ? e.getMessage() : "未知错误";
String lower = message.toLowerCase(Locale.ROOT);
@ -444,7 +474,8 @@ public class ToolExecutionExecutor {
ToolCallback callback,
String arguments,
boolean concurrencySafe,
int resultIndex
int resultIndex,
String conversationId
) {}
private record ApprovalBarrier(String pendingId, String toolName) {}

View File

@ -206,6 +206,10 @@ public class ReasoningNode implements NodeAction {
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
Map.of("iteration", accessor.iterationCount()));
pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(),
"llmCallCount", nextLlmCallCount
));
NodeStreamingChatHelper.StreamResult result;
try {
@ -225,6 +229,11 @@ public class ReasoningNode implements NodeAction {
messages.size(), compactedMessages.size());
// compact retry 是第 2 LLM 调用先递增再调用
nextLlmCallCount++;
pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(),
"llmCallCount", nextLlmCallCount,
"compacted", true
));
result = streamingHelper.streamCall(chatModel, retryPrompt, conversationId, "reasoning_compact_retry");
} else {
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry");
@ -296,6 +305,10 @@ public class ReasoningNode implements NodeAction {
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
result.toolCalls().size(),
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
pushPhase(conversationId, "executing_tool", Map.of(
"iteration", accessor.iterationCount(),
"toolCount", result.toolCalls().size()
));
return MateClawStateAccessor.output()
.needsToolCall(true)
@ -315,6 +328,10 @@ public class ReasoningNode implements NodeAction {
} else {
String content = result.text();
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
pushPhase(conversationId, "drafting_answer", Map.of(
"iteration", accessor.iterationCount(),
"answerChars", content != null ? content.length() : 0
));
return MateClawStateAccessor.output()
.needsToolCall(false)
@ -347,4 +364,12 @@ public class ReasoningNode implements NodeAction {
throw new RuntimeException("无法反序列化 forced_tool_call: " + e.getMessage(), e);
}
}
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
return;
}
streamTracker.updatePhase(conversationId, phase);
streamTracker.broadcastObject(conversationId, "phase", GraphEventPublisher.phase(phase, extra).data());
}
}

View File

@ -82,6 +82,10 @@ public class SummarizingNode implements NodeAction {
log.info("[SummarizingNode] Summarizing {} observations ({} total chars) for user query",
observations.size(), accessor.totalObservationChars());
pushPhase(conversationId, "summarizing_observations", Map.of(
"observationCount", observations.size(),
"summaryChars", accessor.totalObservationChars()
));
// 构建 summarize prompt
StringBuilder observationText = new StringBuilder();
@ -176,4 +180,12 @@ public class SummarizingNode implements NodeAction {
"summaryChars", summaryContent.length()))))
.build();
}
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
return;
}
streamTracker.updatePhase(conversationId, phase);
streamTracker.broadcastObject(conversationId, "phase", GraphEventPublisher.phase(phase, extra).data());
}
}

View File

@ -1218,8 +1218,14 @@ public class ChatController {
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return;
}
if ("phase".equals(delta.eventType())) {
String phase = String.valueOf(delta.eventData().getOrDefault("phase", ""));
if (!phase.isBlank()) {
streamTracker.updatePhase(conversationId, phase);
}
}
// 累积工具调用事件用于持久化到消息历史
accumulateToolEvent(delta.eventType(), delta.eventData());
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try {
broadcastEvent(conversationId, delta.eventType(), delta.eventData());
} catch (Exception e) {
@ -1229,6 +1235,7 @@ public class ChatController {
}
if (delta.content() != null && !delta.content().isBlank()) {
content.append(delta.content());
streamTracker.updatePhase(conversationId, "drafting_answer");
if (!delta.persistenceOnly()) {
broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content()));
}
@ -1243,9 +1250,10 @@ public class ChatController {
boolean isAwaitingApproval() { return awaitingApproval; }
private void accumulateToolEvent(String eventType, Map<String, Object> data) {
private void accumulateToolEvent(String eventType, Map<String, Object> data, String conversationId) {
if ("tool_approval_requested".equals(eventType)) {
awaitingApproval = true;
streamTracker.updatePhase(conversationId, "awaiting_approval");
} else if ("tool_call_started".equals(eventType)) {
Map<String, Object> tc = new LinkedHashMap<>();
tc.put("name", data.getOrDefault("toolName", ""));

View File

@ -2,8 +2,12 @@
<div v-if="isLoading && !hideBar" class="stream-loading-bar">
<div class="stream-loading-content">
<span class="loading-icon" :class="phaseIconClass">{{ phaseIcon }}</span>
<span class="loading-text" :class="phaseTextClass">{{ statusText }}</span>
<span v-if="runningToolName" class="loading-tool">{{ runningToolName }}</span>
<div class="loading-copy">
<span class="loading-text" :class="phaseTextClass">{{ statusText }}</span>
<span v-if="runningToolName" class="loading-tool">{{ runningToolName }}</span>
<span v-if="statusDetail" class="loading-detail">{{ statusDetail }}</span>
<span v-if="slowHint" class="loading-slow">{{ slowHint }}</span>
</div>
</div>
<div class="loading-right">
<!-- 排队指示器 -->
@ -25,7 +29,7 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import type { StreamPhase } from '@/types'
import type { PhaseEventData, StreamPhase } from '@/types'
interface Props {
isLoading: boolean
@ -36,6 +40,8 @@ interface Props {
promptTokens?: number
/** 当前流阶段 */
phase?: StreamPhase
/** 最近一次阶段事件 */
phaseInfo?: PhaseEventData | null
/** 当前正在执行的工具名称 */
runningToolName?: string
/** 是否有排队消息 */
@ -49,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
completionTokens: 0,
promptTokens: 0,
phase: 'thinking',
phaseInfo: null,
runningToolName: '',
hasQueued: false,
})
@ -57,10 +64,17 @@ const { t } = useI18n()
// Phase-aware i18n
const phaseI18nMap: Record<string, string> = {
preparing_context: 'chat.streamPreparingContext',
reading_memory: 'chat.streamReadingMemory',
reasoning: 'chat.streamReasoning',
drafting_answer: 'chat.streamDraftingAnswer',
summarizing_observations: 'chat.streamSummarizingObservations',
thinking: 'chat.streamThinking',
streaming: 'chat.streamGenerating',
executing_tool: 'chat.streamExecutingTool',
awaiting_approval: 'chat.streamAwaitingApproval',
finalizing: 'chat.streamFinalizing',
failed: 'chat.streamFailed',
interrupting: 'chat.streamInterrupting',
queued: 'chat.streamQueued',
reconnecting: 'chat.streamReconnecting',
@ -77,10 +91,17 @@ const statusText = computed(() => {
const phaseIcon = computed(() => {
switch (props.phase) {
case 'preparing_context': return '◔'
case 'reading_memory': return '⌕'
case 'reasoning': return '◐'
case 'drafting_answer': return '✎'
case 'summarizing_observations': return '≋'
case 'thinking': return '◐'
case 'streaming': return '▸'
case 'executing_tool': return '⚙'
case 'awaiting_approval': return '⏸'
case 'finalizing': return '✓'
case 'failed': return '!'
case 'interrupting': return '⊘'
case 'queued': return '◷'
case 'reconnecting': return '↻'
@ -91,6 +112,7 @@ const phaseIcon = computed(() => {
const phaseIconClass = computed(() => {
switch (props.phase) {
case 'awaiting_approval': return 'icon-paused'
case 'failed': return 'icon-warning'
case 'interrupting': return 'icon-warning'
case 'queued': return 'icon-queued'
default: return 'icon-active'
@ -100,12 +122,53 @@ const phaseIconClass = computed(() => {
const phaseTextClass = computed(() => {
switch (props.phase) {
case 'awaiting_approval': return 'text-amber'
case 'failed': return 'text-red'
case 'interrupting': return 'text-red'
case 'queued': return 'text-blue'
default: return ''
}
})
const detailI18nMap: Record<string, string> = {
preparing_context: 'chat.streamPreparingContextDetail',
reading_memory: 'chat.streamReadingMemoryDetail',
reasoning: 'chat.streamReasoningDetail',
drafting_answer: 'chat.streamDraftingAnswerDetail',
summarizing_observations: 'chat.streamSummarizingObservationsDetail',
thinking: 'chat.streamThinkingDetail',
streaming: 'chat.streamGeneratingDetail',
executing_tool: 'chat.streamExecutingToolDetail',
awaiting_approval: 'chat.streamAwaitingApprovalDetail',
finalizing: 'chat.streamFinalizingDetail',
failed: 'chat.streamFailedDetail',
}
const statusDetail = computed(() => {
if (props.phaseInfo?.phase) {
const key = detailI18nMap[props.phaseInfo.phase]
if (key) return t(key)
}
const key = detailI18nMap[props.phase]
return key ? t(key) : ''
})
const slowHint = computed(() => {
const secs = elapsedSeconds.value
if (props.phase === 'summarizing_observations' && secs >= 15) {
return t('chat.streamSlowSummarizing')
}
if ((props.phase === 'reasoning' || props.phase === 'thinking') && secs >= 20) {
return t('chat.streamSlowReasoning')
}
if (secs >= 45) {
return t('chat.streamSlowGeneral')
}
if (secs >= 8) {
return t('chat.streamSlowShort')
}
return ''
})
//
const elapsedSeconds = ref(0)
const elapsedTime = ref('0s')
@ -174,11 +237,18 @@ onBeforeUnmount(() => {
.stream-loading-content {
display: flex;
align-items: center;
align-items: flex-start;
gap: 6px;
color: #f97316;
}
.loading-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.loading-icon {
font-size: 16px;
font-weight: bold;
@ -214,6 +284,7 @@ onBeforeUnmount(() => {
.text-blue { color: #3b82f6; }
.loading-tool {
align-self: flex-start;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
background: rgba(249, 115, 22, 0.1);
@ -226,6 +297,16 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.loading-detail {
font-size: 12px;
color: var(--mc-text-secondary, #94a3b8);
}
.loading-slow {
font-size: 12px;
color: #f59e0b;
}
.loading-right {
display: flex;
align-items: center;

View File

@ -12,7 +12,7 @@ import { ref, computed } from 'vue'
import { useMessages } from './useMessages'
import { useStream } from './useStream'
import { useMessageQueue } from './useMessageQueue'
import type { Message, MessageContentPart, StreamPhase, HeartbeatData, QueuedMessage } from '@/types'
import type { Message, MessageContentPart, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
export interface UseChatOptions {
@ -46,6 +46,8 @@ export interface UseChatReturn {
isGenerating: import('vue').ComputedRef<boolean>
/** 当前流阶段 */
streamPhase: import('vue').Ref<StreamPhase>
/** 最近一次阶段事件 */
phaseInfo: import('vue').Ref<PhaseEventData | null>
/** 当前错误 */
error: import('vue').Ref<Error | null>
/** 排队的消息 */
@ -105,6 +107,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
/** stopGeneration 的 fallback timer新流开始时必须清除防止误杀新连接 */
let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null
const streamPhase = ref<StreamPhase>('idle')
const phaseInfo = ref<PhaseEventData | null>(null)
const heartbeat = ref<HeartbeatData | null>(null)
/** Track which conversation the current stream belongs to */
let streamConversationId = ''
@ -159,7 +162,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('content_delta', (data) => {
if (currentAssistantId.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'text')
if (streamPhase.value === 'thinking') {
if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) {
streamPhase.value = 'streaming'
}
}
@ -168,7 +171,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('thinking_delta', (data) => {
if (currentAssistantId.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking')
streamPhase.value = 'thinking'
if (streamPhase.value !== 'summarizing_observations') {
streamPhase.value = 'thinking'
}
}
})
@ -265,6 +270,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
streamPhase.value = data.status === 'awaiting_approval' ? 'awaiting_approval'
: data.status === 'stopped' ? 'stopped' : 'completed'
if (data.status !== 'awaiting_approval') {
phaseInfo.value = null
}
// 兜底清理排队状态(如果 queued_input_started 已经处理了则这里是 no-op
if (!messageQueue.hasQueued.value) {
@ -307,6 +315,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
error.value = new Error(data.message || '请求失败')
streamPhase.value = 'idle'
phaseInfo.value = null
// 错误时清理排队状态,避免脏残留
messageQueue.clear()
@ -369,11 +378,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('phase', (data) => {
const phase = data.phase as StreamPhase
if (phase) streamPhase.value = phase
if (phase) {
streamPhase.value = phase
phaseInfo.value = { ...data, phase }
}
if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg) {
const metadata = parseMetadata((msg as any).metadata)
// 去重:相同 phase 不触发 updateMessage避免不必要的 Vue 响应式更新
if (metadata.currentPhase === data.phase) return
updateMessage(currentAssistantId.value, {
...msg,
metadata: { ...metadata, currentPhase: data.phase }
@ -538,10 +552,17 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// 从 heartbeat 中更新 phase如果前端还没有更精确的 phase
if (data.currentPhase && streamPhase.value !== 'interrupting') {
const phaseMap: Record<string, StreamPhase> = {
'preparing_context': 'preparing_context',
'reading_memory': 'reading_memory',
'reasoning': 'reasoning',
'drafting_answer': 'drafting_answer',
'summarizing_observations': 'summarizing_observations',
'thinking': 'thinking',
'streaming': 'streaming',
'executing_tool': 'executing_tool',
'awaiting_approval': 'awaiting_approval',
'finalizing': 'finalizing',
'failed': 'failed',
}
const mapped = phaseMap[data.currentPhase]
if (mapped) streamPhase.value = mapped
@ -594,6 +615,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
assistantMessage.conversationId = data.conversationId || streamConversationId
currentAssistantId.value = assistantMessage.id as string
streamPhase.value = 'thinking'
phaseInfo.value = null
})
// ===== 发送消息(支持运行中继续发送) =====
@ -619,6 +641,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
errorFired = false
streamConversationId = conversationId
streamPhase.value = 'thinking'
phaseInfo.value = null
try {
if (!isApprovalCommand) {
@ -684,6 +707,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
assistantMessage.conversationId = conversationId
currentAssistantId.value = assistantMessage.id as string
streamPhase.value = 'thinking'
phaseInfo.value = null
await stream.connect({
agentId,
message: content,
@ -716,6 +740,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// 标记为停止中(让 UI 立即反馈)
streamPhase.value = 'stopped'
phaseInfo.value = null
if (streamConversationId) {
try {
@ -783,6 +808,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
streamConversationId = conversationId
error.value = null
errorFired = false
phaseInfo.value = null
// 创建 assistant 占位消息用于接收重连后的流数据
const assistantMessage = createAssistantMessage('')
@ -841,6 +867,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
messages,
isGenerating,
streamPhase,
phaseInfo,
error,
queuedMessage: messageQueue.queuedMessage,
hasQueued: messageQueue.hasQueued,

View File

@ -136,15 +136,37 @@ export default {
queuedReplace: 'Message queued. Press Enter to replace...',
queuedBadge: '{count} queued',
// Stream status
streamPreparingContext: 'Preparing context...',
streamReadingMemory: 'Reading memory...',
streamReasoning: 'Analyzing...',
streamDraftingAnswer: 'Drafting answer...',
streamSummarizingObservations: 'Organizing information...',
streamThinking: 'Thinking...',
streamGenerating: 'Generating...',
streamExecutingTool: 'Executing tool...',
streamAwaitingApproval: 'Awaiting approval',
streamFinalizing: 'Finalizing...',
streamFailed: 'Interrupted',
streamInterrupting: 'Interrupting...',
streamQueued: 'Queued, waiting...',
streamReconnecting: 'Reconnecting...',
streamStopped: 'Stopped',
streamCompleted: 'Completed',
streamPreparingContextDetail: 'Collecting the current question, conversation history, and required context.',
streamReadingMemoryDetail: 'Looking up the most relevant long-term memory and recent notes for this question.',
streamReasoningDetail: 'Comparing options and organizing the answer structure based on the available information.',
streamDraftingAnswerDetail: 'The main analysis is done. Turning it into a readable answer now.',
streamSummarizingObservationsDetail: 'A larger tool result just came back. Condensing the key points before continuing.',
streamThinkingDetail: 'The model is still reasoning internally. Visible text may not appear immediately.',
streamGeneratingDetail: 'Visible output has started and more content should appear shortly.',
streamExecutingToolDetail: 'Reading files, querying data, or running the necessary operation for a more accurate answer.',
streamAwaitingApprovalDetail: 'Your confirmation is required before continuing.',
streamFinalizingDetail: 'Saving the result and closing out this response.',
streamFailedDetail: 'This run did not finish cleanly, but you can retry or keep asking follow-up questions.',
streamSlowShort: 'This step is taking a bit longer than usual, but it is still running.',
streamSlowSummarizing: 'Organizing a larger tool result. This step often takes longer.',
streamSlowReasoning: 'The model is doing heavier analysis here. This is not a frozen connection.',
streamSlowGeneral: 'Processing is still ongoing. The time is currently being spent in internal analysis.',
// Approval bar
approvalAllow: 'Allow',
approvalExecute: 'to execute?',

View File

@ -136,15 +136,37 @@ export default {
queuedReplace: '消息已排队,按回车替换...',
queuedBadge: '{count} 条排队',
// 流状态
streamPreparingContext: '准备上下文...',
streamReadingMemory: '读取记忆...',
streamReasoning: '分析问题...',
streamDraftingAnswer: '生成回答...',
streamSummarizingObservations: '整理信息...',
streamThinking: '思考中...',
streamGenerating: '生成中...',
streamExecutingTool: '执行工具...',
streamAwaitingApproval: '等待审批',
streamFinalizing: '正在收尾...',
streamFailed: '本轮中断',
streamInterrupting: '中断中...',
streamQueued: '排队中...',
streamReconnecting: '重新连接...',
streamStopped: '已停止',
streamCompleted: '已完成',
streamPreparingContextDetail: '正在收集当前问题、历史对话和必要配置。',
streamReadingMemoryDetail: '正在查找与你当前问题最相关的长期记忆和今日记录。',
streamReasoningDetail: '正在基于现有信息进行判断、比较方案并组织回答结构。',
streamDraftingAnswerDetail: '已经完成主要分析,正在把结论写成可直接阅读的答案。',
streamSummarizingObservationsDetail: '刚刚拿到了较多工具结果,正在提炼重点,避免回答冗长或遗漏关键信息。',
streamThinkingDetail: '正在做内部推理,这一步未必会立即产生正文。',
streamGeneratingDetail: '已经进入可见输出阶段,会陆续显示结果。',
streamExecutingToolDetail: '正在读取文件、查询数据或执行必要操作,以便给出更准确的答案。',
streamAwaitingApprovalDetail: '接下来需要你的确认,确认后我会继续执行。',
streamFinalizingDetail: '正在保存结果并结束当前响应。',
streamFailedDetail: '这次处理没有完整完成,但你可以继续追问或重试。',
streamSlowShort: '这一步比平时慢一些,但系统仍在处理中。',
streamSlowSummarizing: '正在整理较长的工具结果。因为信息较多,这一步可能需要更久。',
streamSlowReasoning: '正在做较复杂的分析和取舍,不是卡住。',
streamSlowGeneral: '处理还在继续,当前主要耗时在内部分析,不代表连接中断。',
// 审批栏
approvalAllow: '允许',
approvalExecute: '执行?',

View File

@ -362,10 +362,17 @@ export const CHANNEL_FIELD_DEFS: Record<string, ChannelFieldDef[]> = {
/** 流阶段(前后端统一命名) */
export type StreamPhase =
| 'preparing_context' // 正在准备上下文
| 'reading_memory' // 正在读取记忆/历史
| 'reasoning' // 正在推理分析
| 'drafting_answer' // 正在起草答案
| 'summarizing_observations' // 正在整理工具结果
| 'thinking' // 模型推理中
| 'streaming' // 正在输出文本
| 'executing_tool' // 正在执行工具
| 'awaiting_approval' // 等待审批
| 'finalizing' // 正在收尾
| 'failed' // 已失败
| 'interrupting' // 正在中断
| 'queued' // 有排队消息
| 'reconnecting' // 正在重连
@ -373,6 +380,17 @@ export type StreamPhase =
| 'completed' // 已完成
| 'idle' // 空闲
/** 阶段事件数据 */
export interface PhaseEventData {
phase: StreamPhase | string
timestamp?: number
toolName?: string
toolCount?: number
observationCount?: number
summaryChars?: number
iteration?: number
}
/** 排队的用户消息 */
export interface QueuedMessage {
/** 消息内容 */

View File

@ -159,6 +159,7 @@
:completion-tokens="currentGeneratingTokens"
:prompt-tokens="currentPromptTokens"
:phase="streamPhase"
:phase-info="phaseInfo"
:running-tool-name="currentRunningToolName"
:has-queued="hasQueued"
/>
@ -363,6 +364,7 @@ const {
messages,
isGenerating,
streamPhase,
phaseInfo,
queuedMessage,
hasQueued,
queueSize,