diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index bfe2683..ffcb54e 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -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: diff --git a/static/src/app.ts b/static/src/app.ts index f2796eb..0cddee6 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -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, diff --git a/static/src/components/chat/StackedBlocks.vue b/static/src/components/chat/StackedBlocks.vue index f5897bb..d40670f 100644 --- a/static/src/components/chat/StackedBlocks.vue +++ b/static/src/components/chat/StackedBlocks.vue @@ -22,7 +22,14 @@
-
+
-
+ @@ -235,7 +242,8 @@ const setShellMetrics = (expandedOverride: Record = {}) => { 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)); }); diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index 0a8f7d3..24a4047 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -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) => { diff --git a/static/src/composables/useScrollControl.ts b/static/src/composables/useScrollControl.ts index 9d64d16..fb95fa4 100644 --- a/static/src/composables/useScrollControl.ts +++ b/static/src/composables/useScrollControl.ts @@ -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) { diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index bed9d9c..49beb40 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -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 || '', diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 396c768..aa35cb9 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -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 { diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index dc6cee9..2c0e4ca 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -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 { diff --git a/web_server.py b/web_server.py index 83d0656..e602da5 100644 --- a/web_server.py +++ b/web_server.py @@ -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}") # 检查是否被停止