fix: intent streaming defaults and UI stability

This commit is contained in:
JOJO 2026-01-01 05:48:13 +08:00
parent 713659a644
commit 4262922694
9 changed files with 312 additions and 69 deletions

View File

@ -35,6 +35,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"disabled_tool_categories": [],
"default_run_mode": None,
"auto_generate_title": True,
"tool_intent_enabled": True,
}
__all__ = [
@ -124,6 +125,12 @@ def sanitize_personalization_payload(
else:
base["thinking_interval"] = _sanitize_thinking_interval(base.get("thinking_interval"))
# 工具意图提示开关
if "tool_intent_enabled" in data:
base["tool_intent_enabled"] = bool(data.get("tool_intent_enabled"))
else:
base["tool_intent_enabled"] = bool(base.get("tool_intent_enabled"))
if "disabled_tool_categories" in data:
base["disabled_tool_categories"] = _sanitize_tool_categories(data.get("disabled_tool_categories"), allowed_tool_categories)
else:

View File

@ -45,7 +45,7 @@ import {
import {
getToolIcon,
getToolAnimationClass,
getToolStatusText,
getToolStatusText as baseGetToolStatusText,
getToolDescription,
cloneToolArguments,
buildToolLabel,
@ -1653,6 +1653,8 @@ const appOptions = {
id: toolCall.id,
name: toolCall.function.name,
arguments: arguments_obj,
intent_full: arguments_obj.intent || '',
intent_rendered: arguments_obj.intent || '',
status: 'preparing',
result: null
},
@ -2585,7 +2587,14 @@ const appOptions = {
},
getToolIcon,
getToolAnimationClass,
getToolStatusText,
getToolStatusText(tool: any) {
const personalization = usePersonalizationStore();
const intentEnabled =
personalization?.form?.tool_intent_enabled ??
personalization?.tool_intent_enabled ??
true;
return baseGetToolStatusText(tool, { intentEnabled });
},
getToolDescription,
cloneToolArguments,
buildToolLabel,

View File

@ -22,7 +22,14 @@
</div>
<div class="stacked-viewport" :style="{ height: `${viewportHeight}px` }">
<div class="stacked-inner" ref="inner" :style="{ transform: `translateY(${innerOffset}px)` }">
<TransitionGroup
name="stacked"
tag="div"
class="stacked-inner"
ref="inner"
:style="{ transform: `translateY(${innerOffset}px)` }"
appear
>
<div v-for="(action, idx) in stackableActions" :key="blockKey(action, idx)" class="stacked-item">
<div
v-if="action.type === 'thinking'"
@ -109,7 +116,7 @@
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
</div>
</div>
</div>
</TransitionGroup>
</div>
</div>
</template>
@ -235,7 +242,8 @@ const setShellMetrics = (expandedOverride: Record<string, boolean> = {}) => {
const contentHeight = content ? Math.ceil(content.scrollHeight) : 0;
nextContentHeights[key] = contentHeight;
const contentH = expanded ? contentHeight : 0;
const progressH = progress ? progress.getBoundingClientRect().height : 0;
//
const progressH = 0;
const borderH = idx < children.length - 1 ? parseFloat(getComputedStyle(el).borderBottomWidth) || 0 : 0;
heights.push(Math.ceil(headerH + contentH + progressH + borderH));
});

View File

@ -39,6 +39,75 @@ export async function initializeLegacySocket(ctx: any) {
const pendingToolEvents: Array<{ event: string; data: any; handler: () => void }> = [];
const startIntentTyping = (action: any, intentText: any) => {
if (!action || !action.tool) {
return;
}
if (typeof intentText !== 'string') {
return;
}
const text = intentText.trim();
if (!text) {
return;
}
if (action.tool.intent_full === text) {
if (console && console.debug) {
console.debug('[tool_intent] skip (same text)', {
id: action?.id || action?.tool?.id,
name: action?.tool?.name,
text
});
}
return;
}
if (typeof console !== 'undefined' && console.debug) {
console.debug('[tool_intent] typing start', {
id: action?.id || action?.tool?.id,
name: action?.tool?.name,
text
});
}
if (action.tool.intent_full === text && action.tool.intent_rendered === text) {
return;
}
// 清理旧定时器
if (action.tool.intent_typing_timer) {
clearInterval(action.tool.intent_typing_timer);
action.tool.intent_typing_timer = null;
}
action.tool.intent_full = text;
action.tool.intent_rendered = '';
const duration = 1000;
const step = Math.max(10, Math.floor(duration / Math.max(1, text.length)));
let index = 0;
action.tool.intent_typing_timer = window.setInterval(() => {
action.tool.intent_rendered = text.slice(0, index + 1);
if (index === 0 && console && console.debug) {
console.debug('[tool_intent] typing tick', {
id: action?.id || action?.tool?.id,
name: action?.tool?.name,
next: action.tool.intent_rendered
});
}
index += 1;
if (index >= text.length) {
clearInterval(action.tool.intent_typing_timer);
action.tool.intent_typing_timer = null;
action.tool.intent_rendered = text;
if (console && console.debug) {
console.debug('[tool_intent] typing done', {
id: action?.id || action?.tool?.id,
name: action?.tool?.name,
full: text
});
}
}
if (typeof ctx?.$forceUpdate === 'function') {
ctx.$forceUpdate();
}
}, step);
};
const hasPendingStreamingText = () =>
!!streamingState.buffer.length ||
!!streamingState.pendingCompleteContent ||
@ -845,6 +914,7 @@ export async function initializeLegacySocket(ctx: any) {
scheduleStreamingFlush();
}
ctx.monitorEndModelOutput();
flushPendingToolEvents();
});
// 工具提示事件(可选)
@ -863,46 +933,76 @@ export async function initializeLegacySocket(ctx: any) {
return;
}
socketLog('工具准备中:', data.name);
if (typeof console !== 'undefined' && console.debug) {
console.debug('[tool_intent] preparing', {
id: data?.id,
name: data?.name,
intent: data?.intent,
convo: data?.conversation_id
});
}
if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) {
socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id);
return;
}
const handler = () => {
const msg = ctx.chatEnsureAssistantMessage();
if (!msg) {
return;
}
const action = {
id: data.id,
type: 'tool',
tool: {
id: data.id,
name: data.name,
arguments: {},
argumentSnapshot: null,
argumentLabel: '',
status: 'preparing',
result: null,
message: data.message || `准备调用 ${data.name}...`
},
timestamp: Date.now()
};
msg.actions.push(action);
ctx.preparingTools.set(data.id, action);
ctx.toolRegisterAction(action, data.id);
ctx.toolTrackAction(data.name, action);
ctx.$forceUpdate();
ctx.conditionalScrollToBottom();
if (ctx.monitorPreviewTool) {
ctx.monitorPreviewTool(data);
}
};
if (hasPendingStreamingText()) {
pendingToolEvents.push({ event: 'tool_preparing', data, handler });
} else {
handler();
const msg = ctx.chatEnsureAssistantMessage();
if (!msg) {
return;
}
const action = {
id: data.id,
type: 'tool',
tool: {
id: data.id,
name: data.name,
arguments: {},
argumentSnapshot: null,
argumentLabel: '',
status: 'preparing',
result: null,
message: data.message || `准备调用 ${data.name}...`,
intent_full: data.intent || '',
intent_rendered: data.intent || ''
},
timestamp: Date.now()
};
msg.actions.push(action);
ctx.preparingTools.set(data.id, action);
ctx.toolRegisterAction(action, data.id);
ctx.toolTrackAction(data.name, action);
if (data.intent) {
startIntentTyping(action, data.intent);
}
ctx.$forceUpdate();
ctx.conditionalScrollToBottom();
if (ctx.monitorPreviewTool) {
ctx.monitorPreviewTool(data);
}
});
// 工具意图(流式增量)事件
ctx.socket.on('tool_intent', (data) => {
if (ctx.dropToolEvents) {
return;
}
if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) {
return;
}
if (typeof console !== 'undefined' && console.debug) {
console.debug('[tool_intent] update', {
id: data?.id,
name: data?.name,
intent: data?.intent,
convo: data?.conversation_id
});
}
const target =
ctx.toolFindAction(data.id, data.preparing_id, data.execution_id) ||
ctx.toolGetLatestAction(data.name);
if (target) {
startIntentTyping(target, data.intent);
}
ctx.$forceUpdate();
});
// 工具状态更新事件 - 实时显示详细状态
@ -919,6 +1019,9 @@ export async function initializeLegacySocket(ctx: any) {
if (target) {
target.tool.statusDetail = data.detail;
target.tool.statusType = data.status;
if (data.intent) {
startIntentTyping(target, data.intent);
}
ctx.$forceUpdate();
return;
}
@ -927,6 +1030,9 @@ export async function initializeLegacySocket(ctx: any) {
fallbackAction.tool.statusDetail = data.detail;
fallbackAction.tool.statusType = data.status;
ctx.toolRegisterAction(fallbackAction, data.execution_id || data.id || data.preparing_id);
if (data.intent) {
startIntentTyping(fallbackAction, data.intent);
}
ctx.$forceUpdate();
}
});
@ -977,6 +1083,9 @@ export async function initializeLegacySocket(ctx: any) {
action.tool.message = null;
action.tool.id = data.id;
action.tool.executionId = data.id;
if (data.arguments && data.arguments.intent) {
startIntentTyping(action, data.arguments.intent);
}
ctx.toolRegisterAction(action, data.id);
ctx.toolTrackAction(data.name, action);
ctx.$forceUpdate();
@ -984,11 +1093,7 @@ export async function initializeLegacySocket(ctx: any) {
ctx.monitorQueueTool(data);
};
if (hasPendingStreamingText()) {
pendingToolEvents.push({ event: 'tool_start', data, handler });
} else {
handler();
}
handler();
});
// 更新action工具完成
@ -1039,6 +1144,9 @@ export async function initializeLegacySocket(ctx: any) {
if (data.message !== undefined) {
targetAction.tool.message = data.message;
}
if (data.arguments && data.arguments.intent) {
startIntentTyping(targetAction, data.arguments.intent);
}
if (data.awaiting_content) {
targetAction.tool.awaiting_content = true;
} else if (data.status === 'completed') {
@ -1051,6 +1159,9 @@ export async function initializeLegacySocket(ctx: any) {
targetAction.tool.arguments = data.arguments;
targetAction.tool.argumentSnapshot = ctx.cloneToolArguments(data.arguments);
targetAction.tool.argumentLabel = ctx.buildToolLabel(targetAction.tool.argumentSnapshot);
if (data.arguments.intent) {
startIntentTyping(targetAction, data.arguments.intent);
}
}
ctx.toolRegisterAction(targetAction, data.execution_id || data.id);
if (data.status && ["completed", "failed", "error"].includes(data.status) && !data.awaiting_content) {
@ -1079,11 +1190,7 @@ export async function initializeLegacySocket(ctx: any) {
ctx.monitorResolveTool(data);
};
if (hasPendingStreamingText()) {
pendingToolEvents.push({ event: 'update_action', data, handler });
} else {
handler();
}
handler();
});
ctx.socket.on('append_payload', (data) => {

View File

@ -11,21 +11,38 @@ type ScrollContext = {
};
export function scrollToBottom(ctx: ScrollContext) {
setTimeout(() => {
const messagesArea = ctx.getMessagesAreaElement?.();
if (!messagesArea) {
return;
}
if (typeof ctx._setScrollingFlag === 'function') {
ctx._setScrollingFlag(true);
}
messagesArea.scrollTop = messagesArea.scrollHeight;
setTimeout(() => {
const messagesArea = ctx.getMessagesAreaElement?.();
if (!messagesArea) {
return;
}
const attempts = [0, 16, 60, 150, 320, 520]; // 多次尝试覆盖布局抖动/异步伸缩
let cancelled = false;
const perform = (idx: number) => {
if (cancelled) return;
if (ctx.userScrolling) {
cancelled = true;
if (typeof ctx._setScrollingFlag === 'function') {
ctx._setScrollingFlag(false);
}
}, 100);
}, 50);
return;
}
if (idx === 0 && typeof ctx._setScrollingFlag === 'function') {
ctx._setScrollingFlag(true);
}
messagesArea.scrollTop = messagesArea.scrollHeight;
if (idx < attempts.length - 1) {
setTimeout(() => perform(idx + 1), attempts[idx + 1] - attempts[idx]);
} else if (typeof ctx._setScrollingFlag === 'function') {
setTimeout(() => ctx._setScrollingFlag && ctx._setScrollingFlag(false), 40);
}
};
perform(0);
}
export function conditionalScrollToBottom(ctx: ScrollContext) {

View File

@ -5,6 +5,7 @@ type RunMode = 'fast' | 'thinking' | 'deep';
interface PersonalForm {
enabled: boolean;
auto_generate_title: boolean;
tool_intent_enabled: boolean;
self_identify: string;
user_name: string;
profession: string;
@ -54,6 +55,7 @@ const EXPERIMENT_STORAGE_KEY = 'agents_personalization_experiments';
const defaultForm = (): PersonalForm => ({
enabled: false,
auto_generate_title: true,
tool_intent_enabled: true,
self_identify: '',
user_name: '',
profession: '',
@ -176,6 +178,7 @@ export const usePersonalizationStore = defineStore('personalization', {
this.form = {
enabled: !!data.enabled,
auto_generate_title: data.auto_generate_title !== false,
tool_intent_enabled: !!data.tool_intent_enabled,
self_identify: data.self_identify || '',
user_name: data.user_name || '',
profession: data.profession || '',

View File

@ -406,8 +406,10 @@
transform: translateY(14px);
}
.stacked-enter-active {
.stacked-enter-active,
.stacked-appear-active {
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 120ms;
}
.stacked-leave-active {
@ -421,6 +423,11 @@
transform: translateY(-12px);
}
.stacked-appear-from {
opacity: 0;
transform: translateY(14px);
}
.stacked-move {
transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
@ -440,6 +447,7 @@
animation: progress-bar 2s ease-in-out infinite;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.collapsible-block.processing .progress-indicator {

View File

@ -148,10 +148,28 @@ function describeReadFileResult(tool: any): string {
return '文件读取完成';
}
export function getToolStatusText(tool: any): string {
export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean }): string {
if (!tool) {
return '';
}
const intentEnabled = opts?.intentEnabled !== false;
const intentText = tool.intent_rendered || tool.intent_full || '';
const hasIntent = !!intentText;
// 错误优先展示
if (
tool.message &&
(tool.status === 'failed' || tool.status === 'error' || (tool.result && tool.result.error))
) {
return tool.message;
}
// 开启时:有 intent 就只显示 intent
if (intentEnabled && hasIntent) {
return intentText;
}
// 关闭 intent 或无 intent 时,显示状态文案
if (tool.message) {
return tool.message;
}
@ -186,7 +204,10 @@ export function getToolStatusText(tool: any): string {
}
return COMPLETED_STATUS_TEXTS[tool.name] || '执行完成';
}
return `${tool.name} - ${tool.status}`;
if (tool.status) {
return `${tool.name} - ${tool.status}`;
}
return tool.name || '';
}
export function getToolDescription(tool: any): string {

View File

@ -2984,6 +2984,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
last_tool_name = ""
auto_fix_attempts = 0
last_tool_call_time = 0
detected_tool_intent: Dict[str, str] = {}
# 设置最大迭代次数
max_iterations = MAX_ITERATIONS_PER_TASK
@ -2993,6 +2994,17 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
pending_modify = None # {"path": str, "tool_call_id": str, "buffer": str, ...}
modify_probe_buffer = ""
def extract_intent_from_partial(arg_str: str) -> Optional[str]:
"""从不完整的JSON字符串中粗略提取 intent 字段,容错用于流式阶段。"""
if not arg_str or "intent" not in arg_str:
return None
import re
# 匹配 "intent": "xxx" 形式,允许前面有换行或空格;宽松匹配未闭合的引号
match = re.search(r'"intent"\s*:\s*"([^"]{0,128})', arg_str, re.IGNORECASE | re.DOTALL)
if match:
return match.group(1)
return None
def resolve_monitor_path(args: Dict[str, Any], fallback: Optional[str] = None) -> Optional[str]:
candidates = [
args.get('path'),
@ -3987,24 +3999,62 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
for existing in tool_calls:
if existing.get("index") == tc.get("index"):
if "function" in tc and "arguments" in tc["function"]:
existing["function"]["arguments"] += tc["function"]["arguments"]
arg_chunk = tc["function"]["arguments"]
existing_fn = existing.get("function", {})
existing_args = existing_fn.get("arguments", "")
existing_fn["arguments"] = (existing_args or "") + arg_chunk
existing["function"] = existing_fn
combined_args = existing_fn.get("arguments", "")
tool_id = existing.get("id") or tc.get("id")
tool_name = (
existing_fn.get("name")
or tc.get("function", {}).get("name", "")
)
intent_value = extract_intent_from_partial(combined_args)
if (
intent_value
and tool_id
and detected_tool_intent.get(tool_id) != intent_value
):
detected_tool_intent[tool_id] = intent_value
brief_log(f"[intent] 增量提取 {tool_name}: {intent_value}")
sender('tool_intent', {
'id': tool_id,
'name': tool_name,
'intent': intent_value,
'conversation_id': conversation_id
})
debug_log(f" 发送工具意图: {tool_name} -> {intent_value}")
await asyncio.sleep(0.01)
found = True
break
if not found and tc.get("id"):
tool_id = tc["id"]
tool_name = tc.get("function", {}).get("name", "")
arguments_str = tc.get("function", {}).get("arguments", "") or ""
# 新工具检测到,立即发送准备事件
if tool_id not in detected_tools and tool_name:
detected_tools[tool_id] = tool_name
# 尝试提前提取 intent
intent_value = None
if arguments_str:
intent_value = extract_intent_from_partial(arguments_str)
if intent_value:
detected_tool_intent[tool_id] = intent_value
brief_log(f"[intent] 预提取 {tool_name}: {intent_value}")
# 立即发送工具准备中事件
brief_log(f"[tool] 准备调用 {tool_name} (id={tool_id}) intent={intent_value or '-'}")
sender('tool_preparing', {
'id': tool_id,
'name': tool_name,
'message': f'准备调用 {tool_name}...'
, 'conversation_id': conversation_id
'message': f'准备调用 {tool_name}...',
'intent': intent_value,
'conversation_id': conversation_id
})
debug_log(f" 发送工具准备事件: {tool_name}")
await asyncio.sleep(0.1)
@ -4015,9 +4065,22 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
"type": "function",
"function": {
"name": tool_name,
"arguments": tc.get("function", {}).get("arguments", "")
"arguments": arguments_str
}
})
# 尝试从增量参数中抽取 intent并单独推送
if tool_id and arguments_str:
intent_value = extract_intent_from_partial(arguments_str)
if intent_value and detected_tool_intent.get(tool_id) != intent_value:
detected_tool_intent[tool_id] = intent_value
sender('tool_intent', {
'id': tool_id,
'name': tool_name,
'intent': intent_value,
'conversation_id': conversation_id
})
debug_log(f" 发送工具意图: {tool_name} -> {intent_value}")
await asyncio.sleep(0.01)
debug_log(f" 新工具: {tool_name}")
# 检查是否被停止