2216 lines
105 KiB
Python
2216 lines
105 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import time
|
||
import re
|
||
import zipfile
|
||
from collections import defaultdict, Counter, deque
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from werkzeug.utils import secure_filename
|
||
|
||
from config import (
|
||
OUTPUT_FORMATS,
|
||
AUTO_FIX_TOOL_CALL,
|
||
AUTO_FIX_MAX_ATTEMPTS,
|
||
MAX_ITERATIONS_PER_TASK,
|
||
MAX_CONSECUTIVE_SAME_TOOL,
|
||
MAX_TOTAL_TOOL_CALLS,
|
||
TOOL_CALL_COOLDOWN,
|
||
MAX_UPLOAD_SIZE,
|
||
DEFAULT_CONVERSATIONS_LIMIT,
|
||
MAX_CONVERSATIONS_LIMIT,
|
||
CONVERSATIONS_DIR,
|
||
DEFAULT_RESPONSE_MAX_TOKENS,
|
||
DEFAULT_PROJECT_PATH,
|
||
LOGS_DIR,
|
||
AGENT_VERSION,
|
||
THINKING_FAST_INTERVAL,
|
||
PROJECT_MAX_STORAGE_MB,
|
||
PROJECT_MAX_STORAGE_BYTES,
|
||
UPLOAD_SCAN_LOG_SUBDIR,
|
||
)
|
||
from modules.personalization_manager import (
|
||
load_personalization_config,
|
||
save_personalization_config,
|
||
THINKING_INTERVAL_MIN,
|
||
THINKING_INTERVAL_MAX,
|
||
)
|
||
from modules.upload_security import UploadSecurityError
|
||
from modules.user_manager import UserWorkspace
|
||
from modules.usage_tracker import QUOTA_DEFAULTS
|
||
from core.web_terminal import WebTerminal
|
||
from utils.tool_result_formatter import format_tool_result_for_context
|
||
from utils.conversation_manager import ConversationManager
|
||
from config.model_profiles import get_model_context_window, get_model_profile
|
||
|
||
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
|
||
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker
|
||
from .utils_common import (
|
||
build_review_lines,
|
||
debug_log,
|
||
log_backend_chunk,
|
||
log_frontend_chunk,
|
||
log_streaming_debug_entry,
|
||
brief_log,
|
||
DEBUG_LOG_FILE,
|
||
CHUNK_BACKEND_LOG_FILE,
|
||
CHUNK_FRONTEND_LOG_FILE,
|
||
STREAMING_DEBUG_LOG_FILE,
|
||
)
|
||
from .security import rate_limited, format_tool_result_notice, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token
|
||
from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot
|
||
from .extensions import socketio
|
||
from .state import (
|
||
MONITOR_FILE_TOOLS,
|
||
MONITOR_MEMORY_TOOLS,
|
||
MONITOR_SNAPSHOT_CHAR_LIMIT,
|
||
MONITOR_MEMORY_ENTRY_LIMIT,
|
||
RATE_LIMIT_BUCKETS,
|
||
FAILURE_TRACKERS,
|
||
pending_socket_tokens,
|
||
usage_trackers,
|
||
MONITOR_SNAPSHOT_CACHE,
|
||
MONITOR_SNAPSHOT_CACHE_LIMIT,
|
||
PROJECT_STORAGE_CACHE,
|
||
PROJECT_STORAGE_CACHE_TTL_SECONDS,
|
||
RECENT_UPLOAD_EVENT_LIMIT,
|
||
RECENT_UPLOAD_FEED_LIMIT,
|
||
THINKING_FAILURE_KEYWORDS,
|
||
TITLE_PROMPT_PATH,
|
||
get_last_active_ts,
|
||
user_manager,
|
||
container_manager,
|
||
custom_tool_registry,
|
||
user_terminals,
|
||
terminal_rooms,
|
||
connection_users,
|
||
stop_flags,
|
||
get_stop_flag,
|
||
set_stop_flag,
|
||
clear_stop_flag,
|
||
)
|
||
from .chat_flow_helpers import (
|
||
detect_malformed_tool_call as _detect_malformed_tool_call,
|
||
detect_tool_failure,
|
||
get_thinking_state,
|
||
mark_force_thinking as _mark_force_thinking,
|
||
mark_suppress_thinking,
|
||
apply_thinking_schedule as _apply_thinking_schedule,
|
||
update_thinking_after_call as _update_thinking_after_call,
|
||
maybe_mark_failure_from_message as _maybe_mark_failure_from_message,
|
||
generate_conversation_title_background as _generate_conversation_title_background,
|
||
)
|
||
|
||
|
||
from .chat_flow_runner_helpers import (
|
||
extract_intent_from_partial,
|
||
resolve_monitor_path,
|
||
resolve_monitor_memory,
|
||
capture_monitor_snapshot,
|
||
)
|
||
|
||
|
||
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str):
|
||
return _generate_conversation_title_background(
|
||
web_terminal=web_terminal,
|
||
conversation_id=conversation_id,
|
||
user_message=user_message,
|
||
username=username,
|
||
socketio_instance=socketio,
|
||
title_prompt_path=TITLE_PROMPT_PATH,
|
||
debug_logger=debug_log,
|
||
)
|
||
|
||
|
||
def mark_force_thinking(terminal: WebTerminal, reason: str = ""):
|
||
return _mark_force_thinking(terminal, reason=reason, debug_logger=debug_log)
|
||
|
||
|
||
def apply_thinking_schedule(terminal: WebTerminal):
|
||
return _apply_thinking_schedule(terminal, default_interval=THINKING_FAST_INTERVAL, debug_logger=debug_log)
|
||
|
||
|
||
def update_thinking_after_call(terminal: WebTerminal):
|
||
return _update_thinking_after_call(terminal, debug_logger=debug_log)
|
||
|
||
|
||
def maybe_mark_failure_from_message(terminal: WebTerminal, content: Optional[str]):
|
||
return _maybe_mark_failure_from_message(
|
||
terminal,
|
||
content,
|
||
failure_keywords=THINKING_FAILURE_KEYWORDS,
|
||
debug_logger=debug_log,
|
||
)
|
||
|
||
|
||
def detect_malformed_tool_call(text):
|
||
return _detect_malformed_tool_call(text)
|
||
|
||
|
||
async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspace, message, images, sender, client_sid, username: str, videos=None):
|
||
"""处理任务并发送消息 - 集成token统计版本"""
|
||
web_terminal = terminal
|
||
conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
|
||
videos = videos or []
|
||
raw_sender = sender
|
||
|
||
def sender(event_type, data):
|
||
"""为关键事件补充会话标识,便于前端定位报错归属。"""
|
||
if not isinstance(data, dict):
|
||
raw_sender(event_type, data)
|
||
return
|
||
payload = data
|
||
if event_type in {"error", "quota_exceeded", "task_stopped", "task_complete"}:
|
||
payload = dict(data)
|
||
current_conv = conversation_id or getattr(web_terminal.context_manager, "current_conversation_id", None)
|
||
if current_conv:
|
||
payload.setdefault("conversation_id", current_conv)
|
||
task_id = getattr(web_terminal, "task_id", None) or client_sid
|
||
if task_id:
|
||
payload.setdefault("task_id", task_id)
|
||
if client_sid:
|
||
payload.setdefault("client_sid", client_sid)
|
||
raw_sender(event_type, payload)
|
||
|
||
# 如果是思考模式,重置状态
|
||
if web_terminal.thinking_mode:
|
||
web_terminal.api_client.start_new_task(force_deep=web_terminal.deep_thinking_mode)
|
||
state = get_thinking_state(web_terminal)
|
||
state["fast_streak"] = 0
|
||
state["force_next"] = False
|
||
state["suppress_next"] = False
|
||
|
||
# 添加到对话历史
|
||
history_len_before = len(getattr(web_terminal.context_manager, "conversation_history", []) or [])
|
||
is_first_user_message = history_len_before == 0
|
||
web_terminal.context_manager.add_conversation("user", message, images=images, videos=videos)
|
||
|
||
if is_first_user_message and getattr(web_terminal, "context_manager", None):
|
||
try:
|
||
personal_config = load_personalization_config(workspace.data_dir)
|
||
except Exception:
|
||
personal_config = {}
|
||
auto_title_enabled = personal_config.get("auto_generate_title", True)
|
||
if auto_title_enabled:
|
||
conv_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
|
||
socketio.start_background_task(
|
||
generate_conversation_title_background,
|
||
web_terminal,
|
||
conv_id,
|
||
message,
|
||
username
|
||
)
|
||
|
||
# === 移除:不在这里计算输入token,改为在每次API调用前计算 ===
|
||
|
||
# 构建上下文和消息(用于API调用)
|
||
context = web_terminal.build_context()
|
||
messages = web_terminal.build_messages(context, message)
|
||
tools = web_terminal.define_tools()
|
||
try:
|
||
profile = get_model_profile(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||
web_terminal.apply_model_profile(profile)
|
||
except Exception as exc:
|
||
debug_log(f"更新模型配置失败: {exc}")
|
||
|
||
# === 上下文预算与安全校验(避免超出模型上下文) ===
|
||
max_context_tokens = get_model_context_window(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||
current_tokens = web_terminal.context_manager.get_current_context_tokens(conversation_id)
|
||
# 提前同步给底层客户端,动态收缩 max_tokens
|
||
web_terminal.api_client.update_context_budget(current_tokens, max_context_tokens)
|
||
if max_context_tokens:
|
||
if current_tokens >= max_context_tokens:
|
||
err_msg = (
|
||
f"当前对话上下文已达 {current_tokens} tokens,超过模型上限 "
|
||
f"{max_context_tokens},请先使用压缩功能或清理对话后再试。"
|
||
)
|
||
debug_log(err_msg)
|
||
web_terminal.context_manager.add_conversation("system", err_msg)
|
||
sender('error', {
|
||
'message': err_msg,
|
||
'status_code': 400,
|
||
'error_type': 'context_overflow'
|
||
})
|
||
return
|
||
usage_percent = (current_tokens / max_context_tokens) * 100
|
||
warned = web_terminal.context_manager.conversation_metadata.get("context_warning_sent", False)
|
||
if usage_percent >= 70 and not warned:
|
||
warn_msg = (
|
||
f"当前对话上下文约占 {usage_percent:.1f}%({current_tokens}/{max_context_tokens}),"
|
||
"建议使用压缩功能。"
|
||
)
|
||
web_terminal.context_manager.conversation_metadata["context_warning_sent"] = True
|
||
web_terminal.context_manager.auto_save_conversation(force=True)
|
||
sender('context_warning', {
|
||
'title': '上下文过长',
|
||
'message': warn_msg,
|
||
'type': 'warning',
|
||
'conversation_id': conversation_id
|
||
})
|
||
|
||
# 开始新的AI消息
|
||
sender('ai_message_start', {})
|
||
|
||
# 增量保存相关变量
|
||
accumulated_response = "" # 累积的响应内容
|
||
is_first_iteration = True # 是否是第一次迭代
|
||
|
||
# 统计和限制变量
|
||
total_iterations = 0
|
||
total_tool_calls = 0
|
||
consecutive_same_tool = defaultdict(int)
|
||
last_tool_name = ""
|
||
auto_fix_attempts = 0
|
||
last_tool_call_time = 0
|
||
detected_tool_intent: Dict[str, str] = {}
|
||
|
||
# 设置最大迭代次数(API 可覆盖);None 表示不限制
|
||
max_iterations_override = getattr(web_terminal, "max_iterations_override", None)
|
||
max_iterations = max_iterations_override if max_iterations_override is not None else MAX_ITERATIONS_PER_TASK
|
||
max_api_retries = 4
|
||
retry_delay_seconds = 10
|
||
|
||
pending_append = None # {"path": str, "tool_call_id": str, "buffer": str, ...}
|
||
append_probe_buffer = ""
|
||
pending_modify = None # {"path": str, "tool_call_id": str, "buffer": str, ...}
|
||
modify_probe_buffer = ""
|
||
|
||
async def finalize_pending_append(response_text: str, stream_completed: bool, finish_reason: str = None) -> Dict:
|
||
"""在流式输出结束后处理追加写入"""
|
||
nonlocal pending_append, append_probe_buffer
|
||
|
||
result = {
|
||
"handled": False,
|
||
"success": False,
|
||
"summary": None,
|
||
"summary_message": None,
|
||
"tool_content": None,
|
||
"tool_call_id": None,
|
||
"path": None,
|
||
"forced": False,
|
||
"error": None,
|
||
"assistant_content": response_text,
|
||
"lines": 0,
|
||
"bytes": 0,
|
||
"finish_reason": finish_reason,
|
||
"appended_content": "",
|
||
"assistant_metadata": None
|
||
}
|
||
|
||
if not pending_append:
|
||
return result
|
||
|
||
state = pending_append
|
||
path = state.get("path")
|
||
tool_call_id = state.get("tool_call_id")
|
||
buffer = state.get("buffer", "")
|
||
start_marker = state.get("start_marker")
|
||
end_marker = state.get("end_marker")
|
||
start_idx = state.get("content_start")
|
||
end_idx = state.get("end_index")
|
||
|
||
display_id = state.get("display_id")
|
||
|
||
result.update({
|
||
"handled": True,
|
||
"path": path,
|
||
"tool_call_id": tool_call_id,
|
||
"display_id": display_id
|
||
})
|
||
|
||
if path is None or tool_call_id is None:
|
||
error_msg = "append_to_file 状态不完整,缺少路径或ID。"
|
||
debug_log(error_msg)
|
||
result["error"] = error_msg
|
||
result["summary_message"] = error_msg
|
||
result["tool_content"] = json.dumps({
|
||
"success": False,
|
||
"error": error_msg
|
||
}, ensure_ascii=False)
|
||
if display_id:
|
||
sender('update_action', {
|
||
'id': display_id,
|
||
'status': 'failed',
|
||
'preparing_id': tool_call_id,
|
||
'message': error_msg
|
||
})
|
||
pending_append = None
|
||
return result
|
||
|
||
if start_idx is None:
|
||
error_msg = f"未检测到格式正确的开始标识 {start_marker}。"
|
||
debug_log(error_msg)
|
||
result["error"] = error_msg
|
||
result["summary_message"] = error_msg
|
||
result["tool_content"] = json.dumps({
|
||
"success": False,
|
||
"path": path,
|
||
"error": error_msg
|
||
}, ensure_ascii=False)
|
||
if display_id:
|
||
sender('update_action', {
|
||
'id': display_id,
|
||
'status': 'failed',
|
||
'preparing_id': tool_call_id,
|
||
'message': error_msg
|
||
})
|
||
pending_append = None
|
||
return result
|
||
|
||
forced = False
|
||
if end_idx is None:
|
||
forced = True
|
||
# 查找下一个<<<,否则使用整个缓冲结尾
|
||
remaining = buffer[start_idx:]
|
||
next_marker = remaining.find("<<<", len(end_marker))
|
||
if next_marker != -1:
|
||
end_idx = start_idx + next_marker
|
||
else:
|
||
end_idx = len(buffer)
|
||
|
||
content = buffer[start_idx:end_idx]
|
||
if content.startswith('\n'):
|
||
content = content[1:]
|
||
|
||
if not content:
|
||
error_msg = "未检测到需要追加的内容,请严格按照<<<APPEND:path>>>...<<<END_APPEND>>>格式输出。"
|
||
debug_log(error_msg)
|
||
result["error"] = error_msg
|
||
result["forced"] = forced
|
||
result["tool_content"] = json.dumps({
|
||
"success": False,
|
||
"path": path,
|
||
"error": error_msg
|
||
}, ensure_ascii=False)
|
||
if display_id:
|
||
sender('update_action', {
|
||
'id': display_id,
|
||
'status': 'failed',
|
||
'preparing_id': tool_call_id,
|
||
'message': error_msg
|
||
})
|
||
pending_append = None
|
||
return result
|
||
|
||
assistant_message_lines = []
|
||
if start_marker:
|
||
assistant_message_lines.append(start_marker)
|
||
assistant_message_lines.append(content)
|
||
if not forced and end_marker:
|
||
assistant_message_lines.append(end_marker)
|
||
assistant_message_text = "\n".join(assistant_message_lines)
|
||
result["assistant_content"] = assistant_message_text
|
||
assistant_metadata = {
|
||
"append_payload": {
|
||
"path": path,
|
||
"tool_call_id": tool_call_id,
|
||
"forced": forced,
|
||
"has_end_marker": not forced
|
||
}
|
||
}
|
||
result["assistant_metadata"] = assistant_metadata
|
||
|
||
write_result = web_terminal.file_manager.append_file(path, content)
|
||
if write_result.get("success"):
|
||
bytes_written = len(content.encode('utf-8'))
|
||
line_count = content.count('\n')
|
||
if content and not content.endswith('\n'):
|
||
line_count += 1
|
||
|
||
summary = f"已向 {path} 追加 {line_count} 行({bytes_written} 字节)"
|
||
if forced:
|
||
summary += "。未检测到 <<<END_APPEND>>> 标记,系统已在流结束处完成写入。如内容未完成,请重新调用 append_to_file 并按标准格式补充;如已完成,可继续后续步骤。"
|
||
|
||
result.update({
|
||
"success": True,
|
||
"summary": summary,
|
||
"summary_message": summary,
|
||
"forced": forced,
|
||
"lines": line_count,
|
||
"bytes": bytes_written,
|
||
"appended_content": content,
|
||
"tool_content": json.dumps({
|
||
"success": True,
|
||
"path": path,
|
||
"lines": line_count,
|
||
"bytes": bytes_written,
|
||
"forced": forced,
|
||
"message": summary,
|
||
"finish_reason": finish_reason
|
||
}, ensure_ascii=False)
|
||
})
|
||
|
||
assistant_meta_payload = result["assistant_metadata"]["append_payload"]
|
||
assistant_meta_payload["lines"] = line_count
|
||
assistant_meta_payload["bytes"] = bytes_written
|
||
assistant_meta_payload["success"] = True
|
||
|
||
summary_payload = {
|
||
"success": True,
|
||
"path": path,
|
||
"lines": line_count,
|
||
"bytes": bytes_written,
|
||
"forced": forced,
|
||
"message": summary
|
||
}
|
||
|
||
if display_id:
|
||
sender('update_action', {
|
||
'id': display_id,
|
||
'status': 'completed',
|
||
'result': summary_payload,
|
||
'preparing_id': tool_call_id,
|
||
'message': summary
|
||
})
|
||
|
||
debug_log(f"追加写入完成: {summary}")
|
||
else:
|
||
error_msg = write_result.get("error", "追加写入失败")
|
||
result.update({
|
||
"error": error_msg,
|
||
"summary_message": error_msg,
|
||
"forced": forced,
|
||
"appended_content": content,
|
||
"tool_content": json.dumps({
|
||
"success": False,
|
||
"path": path,
|
||
"error": error_msg,
|
||
"finish_reason": finish_reason
|
||
}, ensure_ascii=False)
|
||
})
|
||
debug_log(f"追加写入失败: {error_msg}")
|
||
|
||
if result["assistant_metadata"]:
|
||
assistant_meta_payload = result["assistant_metadata"]["append_payload"]
|
||
assistant_meta_payload["lines"] = content.count('\n') + (0 if content.endswith('\n') or not content else 1)
|
||
assistant_meta_payload["bytes"] = len(content.encode('utf-8'))
|
||
assistant_meta_payload["success"] = False
|
||
|
||
failure_payload = {
|
||
"success": False,
|
||
"path": path,
|
||
"error": error_msg,
|
||
"forced": forced
|
||
}
|
||
|
||
if display_id:
|
||
sender('update_action', {
|
||
'id': display_id,
|
||
'status': 'completed',
|
||
'result': failure_payload,
|
||
'preparing_id': tool_call_id,
|
||
'message': error_msg
|
||
})
|
||
|
||
pending_append = None
|
||
append_probe_buffer = ""
|
||
if hasattr(web_terminal, "pending_append_request"):
|
||
web_terminal.pending_append_request = None
|
||
return result
|
||
|
||
async def finalize_pending_modify(response_text: str, stream_completed: bool, finish_reason: str = None) -> Dict:
|
||
"""在流式输出结束后处理修改写入"""
|
||
nonlocal pending_modify, modify_probe_buffer
|
||
|
||
result = {
|
||
"handled": False,
|
||
"success": False,
|
||
"path": None,
|
||
"tool_call_id": None,
|
||
"display_id": None,
|
||
"total_blocks": 0,
|
||
"completed_blocks": [],
|
||
"failed_blocks": [],
|
||
"forced": False,
|
||
"details": [],
|
||
"error": None,
|
||
"assistant_content": response_text,
|
||
"assistant_metadata": None,
|
||
"tool_content": None,
|
||
"summary_message": None,
|
||
"finish_reason": finish_reason
|
||
}
|
||
|
||
if not pending_modify:
|
||
return result
|
||
|
||
state = pending_modify
|
||
path = state.get("path")
|
||
tool_call_id = state.get("tool_call_id")
|
||
display_id = state.get("display_id")
|
||
start_marker = state.get("start_marker")
|
||
end_marker = state.get("end_marker")
|
||
buffer = state.get("buffer", "")
|
||
raw_buffer = state.get("raw_buffer", "")
|
||
end_index = state.get("end_index")
|
||
|
||
result.update({
|
||
"handled": True,
|
||
"path": path,
|
||
"tool_call_id": tool_call_id,
|
||
"display_id": display_id
|
||
})
|
||
|
||
if not state.get("start_seen"):
|
||
error_msg = "未检测到格式正确的 <<<MODIFY:path>>> 标记。"
|
||
debug_log(error_msg)
|
||
result["error"] = error_msg
|
||
result["summary_message"] = error_msg
|
||
result["tool_content"] = json.dumps({
|
||
"success": False,
|
||
"path": path,
|
||
"error": error_msg,
|
||
"finish_reason": finish_reason
|
||
}, ensure_ascii=False)
|
||
if display_id:
|
||
sender('update_action', {
|
||
'id': display_id,
|
||
'status': 'failed',
|
||
'preparing_id': tool_call_id,
|
||
'message': error_msg
|
||
})
|
||
if hasattr(web_terminal, "pending_modify_request"):
|
||
web_terminal.pending_modify_request = None
|
||
pending_modify = None
|
||
modify_probe_buffer = ""
|
||
return result
|
||
|
||
forced = end_index is None
|
||
apply_text = buffer if forced else buffer[:end_index]
|
||
raw_content = raw_buffer if forced else raw_buffer[:len(start_marker) + end_index + len(end_marker)]
|
||
if raw_content:
|
||
result["assistant_content"] = raw_content
|
||
|
||
blocks_info = []
|
||
block_reports = {}
|
||
detected_indices = set()
|
||
block_pattern = re.compile(r"\[replace:(\d+)\](.*?)\[/replace\]", re.DOTALL)
|
||
structure_warnings: List[str] = []
|
||
structure_detail_entries: List[Dict] = []
|
||
|
||
def record_structure_warning(message: str, hint: Optional[str] = None):
|
||
"""记录结构性缺陷,便于给出更具体的反馈。"""
|
||
if message in structure_warnings:
|
||
return
|
||
structure_warnings.append(message)
|
||
structure_detail_entries.append({
|
||
"index": 0,
|
||
"status": "failed",
|
||
"reason": message,
|
||
"removed_lines": 0,
|
||
"added_lines": 0,
|
||
"hint": hint or "请严格按照模板输出:[replace:n] + <<OLD>>/<<NEW>> + [/replace],并使用 <<<END_MODIFY>>> 收尾。"
|
||
})
|
||
|
||
def extract_segment(body: str, tag: str):
|
||
marker = f"<<{tag}>>"
|
||
end_tag = "<<END>>"
|
||
start_pos = body.find(marker)
|
||
if start_pos == -1:
|
||
return None, f"缺少 {marker}"
|
||
start_pos += len(marker)
|
||
if body[start_pos:start_pos+2] == "\r\n":
|
||
start_pos += 2
|
||
elif body[start_pos:start_pos+1] == "\n":
|
||
start_pos += 1
|
||
end_pos = body.find(end_tag, start_pos)
|
||
if end_pos == -1:
|
||
return None, f"缺少 {end_tag}"
|
||
segment = body[start_pos:end_pos]
|
||
return segment, None
|
||
|
||
for match in block_pattern.finditer(apply_text):
|
||
try:
|
||
index = int(match.group(1))
|
||
except ValueError:
|
||
continue
|
||
body = match.group(2)
|
||
if index in detected_indices:
|
||
continue
|
||
detected_indices.add(index)
|
||
block_reports[index] = {
|
||
"index": index,
|
||
"status": "pending",
|
||
"reason": None,
|
||
"removed_lines": 0,
|
||
"added_lines": 0,
|
||
"hint": None
|
||
}
|
||
old_content, old_error = extract_segment(body, "OLD")
|
||
new_content, new_error = extract_segment(body, "NEW")
|
||
if old_error or new_error:
|
||
reason = old_error or new_error
|
||
block_reports[index]["status"] = "failed"
|
||
block_reports[index]["reason"] = reason
|
||
blocks_info.append({
|
||
"index": index,
|
||
"old": old_content,
|
||
"new": new_content,
|
||
"error": old_error or new_error
|
||
})
|
||
|
||
if not blocks_info:
|
||
has_replace_start = bool(re.search(r"\[replace:\s*\d+\]", apply_text))
|
||
has_replace_end = "[/replace]" in apply_text
|
||
has_old_tag = "<<OLD>>" in apply_text
|
||
has_new_tag = "<<NEW>>" in apply_text
|
||
|
||
if has_replace_start and not has_replace_end:
|
||
record_structure_warning("检测到 [replace:n] 标记但缺少对应的 [/replace] 结束标记。")
|
||
if has_replace_end and not has_replace_start:
|
||
record_structure_warning("检测到 [/replace] 结束标记但缺少对应的 [replace:n] 起始标记。")
|
||
|
||
old_tags = len(re.findall(r"<<OLD>>", apply_text))
|
||
completed_old_tags = len(re.findall(r"<<OLD>>[\s\S]*?<<END>>", apply_text))
|
||
if old_tags and completed_old_tags < old_tags:
|
||
record_structure_warning("检测到 <<OLD>> 段落但未看到对应的 <<END>> 结束标记。")
|
||
|
||
new_tags = len(re.findall(r"<<NEW>>", apply_text))
|
||
completed_new_tags = len(re.findall(r"<<NEW>>[\s\S]*?<<END>>", apply_text))
|
||
if new_tags and completed_new_tags < new_tags:
|
||
record_structure_warning("检测到 <<NEW>> 段落但未看到对应的 <<END>> 结束标记。")
|
||
|
||
if (has_replace_start or has_replace_end or has_old_tag or has_new_tag) and not structure_warnings:
|
||
record_structure_warning("检测到部分补丁标记,但整体结构不完整,请严格按照模板填写所有标记。")
|
||
|
||
total_blocks = len(blocks_info)
|
||
result["total_blocks"] = total_blocks
|
||
if forced:
|
||
debug_log("未检测到 <<<END_MODIFY>>>,将在流结束处执行已识别的修改块。")
|
||
result["forced"] = True
|
||
|
||
blocks_to_apply = [
|
||
{"index": block["index"], "old": block["old"], "new": block["new"]}
|
||
for block in blocks_info
|
||
if block["error"] is None and block["old"] is not None and block["new"] is not None
|
||
]
|
||
|
||
# 记录格式残缺的块
|
||
for block in blocks_info:
|
||
if block["error"]:
|
||
idx = block["index"]
|
||
block_reports[idx]["status"] = "failed"
|
||
block_reports[idx]["reason"] = block["error"]
|
||
block_reports[idx]["hint"] = "请检查补丁块的 OLD/NEW 标记是否完整,必要时复用 terminal_snapshot 或终端命令重新调整。"
|
||
|
||
apply_result = {}
|
||
if blocks_to_apply:
|
||
apply_result = web_terminal.file_manager.apply_modify_blocks(path, blocks_to_apply)
|
||
else:
|
||
apply_result = {"success": False, "completed": [], "failed": [], "results": [], "write_performed": False, "error": None}
|
||
|
||
block_result_map = {item["index"]: item for item in apply_result.get("results", [])}
|
||
|
||
for block in blocks_info:
|
||
idx = block["index"]
|
||
report = block_reports.get(idx)
|
||
if report is None:
|
||
continue
|
||
if report["status"] == "failed":
|
||
continue
|
||
block_apply = block_result_map.get(idx)
|
||
if not block_apply:
|
||
report["status"] = "failed"
|
||
report["reason"] = "未执行,可能未找到匹配原文"
|
||
report["hint"] = report.get("hint") or "请确认 OLD 文本与文件内容完全一致;若多次失败,可改用终端命令/Python 进行精准替换。"
|
||
continue
|
||
status = block_apply.get("status")
|
||
report["removed_lines"] = block_apply.get("removed_lines", 0)
|
||
report["added_lines"] = block_apply.get("added_lines", 0)
|
||
if block_apply.get("hint"):
|
||
report["hint"] = block_apply.get("hint")
|
||
if status == "success":
|
||
report["status"] = "completed"
|
||
elif status == "not_found":
|
||
report["status"] = "failed"
|
||
report["reason"] = block_apply.get("reason") or "未找到匹配的原文"
|
||
if not report.get("hint"):
|
||
report["hint"] = "请使用 terminal_snapshot/grep -n 校验原文,或在说明后改用 run_command/python 精确替换。"
|
||
else:
|
||
report["status"] = "failed"
|
||
report["reason"] = block_apply.get("reason") or "替换失败"
|
||
if not report.get("hint"):
|
||
report["hint"] = block_apply.get("hint") or "若多次尝试仍失败,可考虑利用终端命令或 Python 小脚本完成此次修改。"
|
||
|
||
completed_blocks = sorted([idx for idx, rep in block_reports.items() if rep["status"] == "completed"])
|
||
failed_blocks = sorted([idx for idx, rep in block_reports.items() if rep["status"] != "completed"])
|
||
|
||
result["completed_blocks"] = completed_blocks
|
||
result["failed_blocks"] = failed_blocks
|
||
details = sorted(block_reports.values(), key=lambda x: x["index"])
|
||
if structure_detail_entries:
|
||
details = structure_detail_entries + details
|
||
result["details"] = details
|
||
|
||
summary_parts = []
|
||
if total_blocks == 0:
|
||
summary_parts.append("未检测到有效的修改块,未执行任何修改。")
|
||
summary_parts.extend(structure_warnings)
|
||
else:
|
||
if not completed_blocks and failed_blocks:
|
||
summary_parts.append(f"共检测到 {total_blocks} 个修改块,全部未执行。")
|
||
elif completed_blocks and not failed_blocks:
|
||
summary_parts.append(f"共 {total_blocks} 个修改块全部完成。")
|
||
else:
|
||
summary_parts.append(
|
||
f"共检测到 {total_blocks} 个修改块,其中成功 {len(completed_blocks)} 个,失败 {len(failed_blocks)} 个。"
|
||
)
|
||
if forced:
|
||
summary_parts.append("未检测到 <<<END_MODIFY>>> 标记,系统已在流结束处执行补丁。")
|
||
if apply_result.get("error"):
|
||
summary_parts.append(apply_result["error"])
|
||
|
||
matching_note = "提示:补丁匹配基于完整文本,包含注释和空白符,请确保 <<<OLD>>> 段落与文件内容逐字一致。如果修改成功,请忽略,如果失败,请明确原文后再次尝试。"
|
||
summary_parts.append(matching_note)
|
||
summary_message = " ".join(summary_parts).strip()
|
||
result["summary_message"] = summary_message
|
||
result["success"] = bool(completed_blocks) and not failed_blocks and apply_result.get("error") is None
|
||
|
||
tool_payload = {
|
||
"success": result["success"],
|
||
"path": path,
|
||
"total_blocks": total_blocks,
|
||
"completed": completed_blocks,
|
||
"failed": [
|
||
{
|
||
"index": rep["index"],
|
||
"reason": rep.get("reason"),
|
||
"hint": rep.get("hint")
|
||
}
|
||
for rep in result["details"] if rep["status"] != "completed"
|
||
],
|
||
"forced": forced,
|
||
"message": summary_message,
|
||
"finish_reason": finish_reason,
|
||
"details": result["details"]
|
||
}
|
||
if apply_result.get("error"):
|
||
tool_payload["error"] = apply_result["error"]
|
||
|
||
result["tool_content"] = json.dumps(tool_payload, ensure_ascii=False)
|
||
result["assistant_metadata"] = {
|
||
"modify_payload": {
|
||
"path": path,
|
||
"total_blocks": total_blocks,
|
||
"completed": completed_blocks,
|
||
"failed": failed_blocks,
|
||
"forced": forced,
|
||
"details": result["details"]
|
||
}
|
||
}
|
||
|
||
if display_id:
|
||
sender('update_action', {
|
||
'id': display_id,
|
||
'status': 'completed' if result["success"] else 'failed',
|
||
'result': tool_payload,
|
||
'preparing_id': tool_call_id,
|
||
'message': summary_message
|
||
})
|
||
|
||
pending_modify = None
|
||
modify_probe_buffer = ""
|
||
if hasattr(web_terminal, "pending_modify_request"):
|
||
web_terminal.pending_modify_request = None
|
||
return result
|
||
|
||
async def process_sub_agent_updates(
|
||
messages: List[Dict],
|
||
inline: bool = False,
|
||
after_tool_call_id: Optional[str] = None
|
||
):
|
||
"""轮询子智能体任务并通知前端,并把结果插入当前对话上下文。"""
|
||
manager = getattr(web_terminal, "sub_agent_manager", None)
|
||
if not manager:
|
||
return
|
||
try:
|
||
updates = manager.poll_updates()
|
||
debug_log(f"[SubAgent] poll inline={inline} updates={len(updates)}")
|
||
except Exception as exc:
|
||
debug_log(f"子智能体状态检查失败: {exc}")
|
||
return
|
||
for update in updates:
|
||
message = update.get("system_message")
|
||
if not message:
|
||
continue
|
||
task_id = update.get("task_id")
|
||
debug_log(f"[SubAgent] update task={task_id} inline={inline} msg={message}")
|
||
web_terminal._record_sub_agent_message(message, task_id, inline=inline)
|
||
debug_log(f"[SubAgent] recorded task={task_id}, 计算插入位置")
|
||
|
||
insert_index = len(messages)
|
||
if after_tool_call_id:
|
||
for idx, msg in enumerate(messages):
|
||
if msg.get("role") == "tool" and msg.get("tool_call_id") == after_tool_call_id:
|
||
insert_index = idx + 1
|
||
break
|
||
|
||
messages.insert(insert_index, {
|
||
"role": "system",
|
||
"content": message,
|
||
"metadata": {"sub_agent_notice": True, "inline": inline, "task_id": task_id}
|
||
})
|
||
debug_log(f"[SubAgent] 插入系统消息位置: {insert_index}")
|
||
sender('system_message', {
|
||
'content': message,
|
||
'inline': inline
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, message)
|
||
|
||
async def _wait_retry_delay(delay_seconds: int) -> bool:
|
||
"""等待重试间隔,同时检查是否收到停止请求。"""
|
||
if delay_seconds <= 0:
|
||
return False
|
||
deadline = time.time() + delay_seconds
|
||
while time.time() < deadline:
|
||
client_stop_info = get_stop_flag(client_sid, username)
|
||
if client_stop_info:
|
||
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
|
||
if stop_requested:
|
||
sender('task_stopped', {
|
||
'message': '命令执行被用户取消',
|
||
'reason': 'user_stop'
|
||
})
|
||
clear_stop_flag(client_sid, username)
|
||
return True
|
||
await asyncio.sleep(0.2)
|
||
return False
|
||
|
||
iteration = 0
|
||
while max_iterations is None or iteration < max_iterations:
|
||
current_iteration = iteration + 1
|
||
iteration += 1
|
||
total_iterations += 1
|
||
iteration_limit_label = max_iterations if max_iterations is not None else "∞"
|
||
debug_log(f"\n--- 迭代 {current_iteration}/{iteration_limit_label} 开始 ---")
|
||
|
||
# 检查是否超过总工具调用限制
|
||
if MAX_TOTAL_TOOL_CALLS is not None and total_tool_calls >= MAX_TOTAL_TOOL_CALLS:
|
||
debug_log(f"已达到最大工具调用次数限制 ({MAX_TOTAL_TOOL_CALLS})")
|
||
sender('system_message', {
|
||
'content': f'⚠️ 已达到最大工具调用次数限制 ({MAX_TOTAL_TOOL_CALLS}),任务结束。'
|
||
})
|
||
mark_force_thinking(web_terminal, reason="tool_limit")
|
||
break
|
||
|
||
apply_thinking_schedule(web_terminal)
|
||
|
||
full_response = ""
|
||
tool_calls = []
|
||
current_thinking = ""
|
||
detected_tools = {}
|
||
last_usage_payload = None
|
||
|
||
# 状态标志
|
||
in_thinking = False
|
||
thinking_started = False
|
||
thinking_ended = False
|
||
text_started = False
|
||
text_has_content = False
|
||
text_streaming = False
|
||
text_chunk_index = 0
|
||
last_text_chunk_time: Optional[float] = None
|
||
|
||
# 计数器
|
||
chunk_count = 0
|
||
reasoning_chunks = 0
|
||
content_chunks = 0
|
||
tool_chunks = 0
|
||
append_break_triggered = False
|
||
append_result = {"handled": False}
|
||
modify_break_triggered = False
|
||
modify_result = {"handled": False}
|
||
last_finish_reason = None
|
||
|
||
def _cancel_pending_tools(tool_calls_list):
|
||
"""为尚未返回结果的工具生成取消结果,防止缺失 tool_call_id 造成后续 400。"""
|
||
if not tool_calls_list:
|
||
return
|
||
for tc in tool_calls_list:
|
||
tc_id = tc.get("id")
|
||
func_name = tc.get("function", {}).get("name")
|
||
sender('update_action', {
|
||
'preparing_id': tc_id,
|
||
'status': 'cancelled',
|
||
'result': {
|
||
"success": False,
|
||
"status": "cancelled",
|
||
"message": "命令执行被用户取消",
|
||
"tool": func_name
|
||
}
|
||
})
|
||
if tc_id:
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc_id,
|
||
"name": func_name,
|
||
"content": "命令执行被用户取消",
|
||
"metadata": {"status": "cancelled"}
|
||
})
|
||
|
||
thinking_expected = web_terminal.api_client.get_current_thinking_mode()
|
||
debug_log(f"思考模式: {thinking_expected}")
|
||
quota_allowed = True
|
||
quota_info = {}
|
||
if hasattr(web_terminal, "record_model_call"):
|
||
quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected))
|
||
if not quota_allowed:
|
||
quota_type = 'thinking' if thinking_expected else 'fast'
|
||
socketio.emit('quota_notice', {
|
||
'type': quota_type,
|
||
'reset_at': quota_info.get('reset_at'),
|
||
'limit': quota_info.get('limit'),
|
||
'count': quota_info.get('count')
|
||
}, room=f"user_{getattr(web_terminal, 'username', '')}")
|
||
sender('quota_exceeded', {
|
||
'type': quota_type,
|
||
'reset_at': quota_info.get('reset_at')
|
||
})
|
||
sender('error', {
|
||
'message': "配额已达到上限,暂时无法继续调用模型。",
|
||
'quota': quota_info
|
||
})
|
||
return
|
||
|
||
tool_call_limit_label = MAX_TOTAL_TOOL_CALLS if MAX_TOTAL_TOOL_CALLS is not None else "∞"
|
||
print(f"[API] 第{current_iteration}次调用 (总工具调用: {total_tool_calls}/{tool_call_limit_label})")
|
||
|
||
api_error = None
|
||
for api_attempt in range(max_api_retries + 1):
|
||
api_error = None
|
||
if api_attempt > 0:
|
||
full_response = ""
|
||
tool_calls = []
|
||
current_thinking = ""
|
||
detected_tools = {}
|
||
last_usage_payload = None
|
||
in_thinking = False
|
||
thinking_started = False
|
||
thinking_ended = False
|
||
text_started = False
|
||
text_has_content = False
|
||
text_streaming = False
|
||
text_chunk_index = 0
|
||
last_text_chunk_time = None
|
||
chunk_count = 0
|
||
reasoning_chunks = 0
|
||
content_chunks = 0
|
||
tool_chunks = 0
|
||
append_break_triggered = False
|
||
append_result = {"handled": False}
|
||
modify_break_triggered = False
|
||
modify_result = {"handled": False}
|
||
last_finish_reason = None
|
||
|
||
# 收集流式响应
|
||
async for chunk in web_terminal.api_client.chat(messages, tools, stream=True):
|
||
chunk_count += 1
|
||
|
||
# 检查停止标志
|
||
client_stop_info = get_stop_flag(client_sid, username)
|
||
if client_stop_info:
|
||
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
|
||
if stop_requested:
|
||
debug_log(f"检测到停止请求,中断流处理")
|
||
if pending_append:
|
||
append_result = await finalize_pending_append(full_response, False, finish_reason="user_stop")
|
||
if pending_modify:
|
||
modify_result = await finalize_pending_modify(full_response, False, finish_reason="user_stop")
|
||
_cancel_pending_tools(tool_calls)
|
||
sender('task_stopped', {
|
||
'message': '命令执行被用户取消',
|
||
'reason': 'user_stop'
|
||
})
|
||
clear_stop_flag(client_sid, username)
|
||
return
|
||
|
||
if isinstance(chunk, dict) and chunk.get("error"):
|
||
api_error = chunk.get("error")
|
||
break
|
||
|
||
# 先尝试记录 usage(有些平台会在最后一个 chunk 里携带 usage 但 choices 为空)
|
||
usage_info = chunk.get("usage")
|
||
if usage_info:
|
||
last_usage_payload = usage_info
|
||
|
||
if "choices" not in chunk:
|
||
debug_log(f"Chunk {chunk_count}: 无choices字段")
|
||
continue
|
||
if not chunk.get("choices"):
|
||
debug_log(f"Chunk {chunk_count}: choices为空列表")
|
||
continue
|
||
choice = chunk["choices"][0]
|
||
if not usage_info and isinstance(choice, dict) and choice.get("usage"):
|
||
# 兼容部分供应商将 usage 放在 choice 内的格式(例如部分 Kimi/Qwen 返回)
|
||
last_usage_payload = choice.get("usage")
|
||
delta = choice.get("delta", {})
|
||
finish_reason = choice.get("finish_reason")
|
||
if finish_reason:
|
||
last_finish_reason = finish_reason
|
||
|
||
# 处理思考内容(兼容 reasoning_content / reasoning_details)
|
||
reasoning_content = ""
|
||
if "reasoning_content" in delta:
|
||
reasoning_content = delta.get("reasoning_content") or ""
|
||
elif "reasoning_details" in delta:
|
||
details = delta.get("reasoning_details")
|
||
if isinstance(details, list):
|
||
parts = []
|
||
for item in details:
|
||
if isinstance(item, dict):
|
||
text = item.get("text")
|
||
if text:
|
||
parts.append(text)
|
||
if parts:
|
||
reasoning_content = "".join(parts)
|
||
if reasoning_content:
|
||
reasoning_chunks += 1
|
||
debug_log(f" 思考内容 #{reasoning_chunks}: {len(reasoning_content)} 字符")
|
||
|
||
if not thinking_started:
|
||
in_thinking = True
|
||
thinking_started = True
|
||
sender('thinking_start', {})
|
||
await asyncio.sleep(0.05)
|
||
|
||
current_thinking += reasoning_content
|
||
sender('thinking_chunk', {'content': reasoning_content})
|
||
|
||
# 处理正常内容
|
||
if "content" in delta:
|
||
content = delta["content"]
|
||
if content:
|
||
content_chunks += 1
|
||
debug_log(f" 正式内容 #{content_chunks}: {repr(content[:100] if content else 'None')}")
|
||
|
||
if in_thinking and not thinking_ended:
|
||
in_thinking = False
|
||
thinking_ended = True
|
||
sender('thinking_end', {'full_content': current_thinking})
|
||
await asyncio.sleep(0.1)
|
||
|
||
|
||
expecting_modify = bool(pending_modify) or bool(getattr(web_terminal, "pending_modify_request", None))
|
||
expecting_append = bool(pending_append) or bool(getattr(web_terminal, "pending_append_request", None))
|
||
|
||
if pending_modify:
|
||
if not pending_modify.get("start_seen"):
|
||
probe_buffer = pending_modify.get("probe_buffer", "") + content
|
||
if len(probe_buffer) > 10000:
|
||
probe_buffer = probe_buffer[-10000:]
|
||
marker = pending_modify.get("start_marker")
|
||
marker_index = probe_buffer.find(marker)
|
||
if marker_index == -1:
|
||
pending_modify["probe_buffer"] = probe_buffer
|
||
continue
|
||
after_marker = marker_index + len(marker)
|
||
remainder = probe_buffer[after_marker:]
|
||
pending_modify["buffer"] = remainder
|
||
pending_modify["raw_buffer"] = marker + remainder
|
||
pending_modify["start_seen"] = True
|
||
pending_modify["detected_blocks"] = set()
|
||
pending_modify["probe_buffer"] = ""
|
||
if pending_modify.get("display_id"):
|
||
sender('update_action', {
|
||
'id': pending_modify["display_id"],
|
||
'status': 'running',
|
||
'preparing_id': pending_modify.get("tool_call_id"),
|
||
'message': f"正在修改 {pending_modify['path']}..."
|
||
})
|
||
else:
|
||
pending_modify["buffer"] += content
|
||
pending_modify["raw_buffer"] += content
|
||
|
||
if pending_modify.get("start_seen"):
|
||
block_text = pending_modify["buffer"]
|
||
for match in re.finditer(r"\[replace:(\d+)\]", block_text):
|
||
try:
|
||
block_index = int(match.group(1))
|
||
except ValueError:
|
||
continue
|
||
detected_blocks = pending_modify.setdefault("detected_blocks", set())
|
||
if block_index not in detected_blocks:
|
||
detected_blocks.add(block_index)
|
||
if pending_modify.get("display_id"):
|
||
sender('update_action', {
|
||
'id': pending_modify["display_id"],
|
||
'status': 'running',
|
||
'preparing_id': pending_modify.get("tool_call_id"),
|
||
'message': f"正在对 {pending_modify['path']} 进行第 {block_index} 处修改..."
|
||
})
|
||
|
||
if pending_modify.get("start_seen"):
|
||
end_pos = pending_modify["buffer"].find(pending_modify["end_marker"])
|
||
if end_pos != -1:
|
||
pending_modify["end_index"] = end_pos
|
||
modify_break_triggered = True
|
||
debug_log("检测到<<<END_MODIFY>>>,即将终止流式输出并应用修改")
|
||
break
|
||
continue
|
||
elif expecting_modify:
|
||
modify_probe_buffer += content
|
||
if len(modify_probe_buffer) > 10000:
|
||
modify_probe_buffer = modify_probe_buffer[-10000:]
|
||
|
||
marker_match = re.search(r"<<<MODIFY:\s*([\s\S]*?)>>>", modify_probe_buffer)
|
||
if marker_match:
|
||
detected_raw_path = marker_match.group(1)
|
||
detected_path = detected_raw_path.strip()
|
||
marker_full = marker_match.group(0)
|
||
after_marker_index = modify_probe_buffer.find(marker_full) + len(marker_full)
|
||
remainder = modify_probe_buffer[after_marker_index:]
|
||
modify_probe_buffer = ""
|
||
|
||
if not detected_path:
|
||
debug_log("检测到 MODIFY 起始标记但路径为空,忽略。")
|
||
continue
|
||
|
||
pending_modify = {
|
||
"path": detected_path,
|
||
"tool_call_id": None,
|
||
"buffer": remainder,
|
||
"raw_buffer": marker_full + remainder,
|
||
"start_marker": marker_full,
|
||
"end_marker": "<<<END_MODIFY>>>",
|
||
"start_seen": True,
|
||
"end_index": None,
|
||
"display_id": None,
|
||
"detected_blocks": set()
|
||
}
|
||
if hasattr(web_terminal, "pending_modify_request"):
|
||
web_terminal.pending_modify_request = {"path": detected_path}
|
||
debug_log(f"直接检测到modify起始标记,构建修改缓冲: {detected_path}")
|
||
|
||
end_pos = pending_modify["buffer"].find(pending_modify["end_marker"])
|
||
if end_pos != -1:
|
||
pending_modify["end_index"] = end_pos
|
||
modify_break_triggered = True
|
||
debug_log("检测到<<<END_MODIFY>>>,即将终止流式输出并应用修改")
|
||
break
|
||
continue
|
||
|
||
if pending_append:
|
||
pending_append["buffer"] += content
|
||
|
||
if pending_append.get("content_start") is None:
|
||
marker_index = pending_append["buffer"].find(pending_append["start_marker"])
|
||
if marker_index != -1:
|
||
pending_append["content_start"] = marker_index + len(pending_append["start_marker"])
|
||
debug_log(f"检测到追加起始标识: {pending_append['start_marker']}")
|
||
|
||
if pending_append.get("content_start") is not None:
|
||
end_index = pending_append["buffer"].find(
|
||
pending_append["end_marker"],
|
||
pending_append["content_start"]
|
||
)
|
||
if end_index != -1:
|
||
pending_append["end_index"] = end_index
|
||
append_break_triggered = True
|
||
debug_log("检测到<<<END_APPEND>>>,即将终止流式输出并写入文件")
|
||
break
|
||
|
||
# 继续累积追加内容
|
||
continue
|
||
elif expecting_append:
|
||
append_probe_buffer += content
|
||
# 限制缓冲区大小防止过长
|
||
if len(append_probe_buffer) > 10000:
|
||
append_probe_buffer = append_probe_buffer[-10000:]
|
||
|
||
marker_match = re.search(r"<<<APPEND:\s*([\s\S]*?)>>>", append_probe_buffer)
|
||
if marker_match:
|
||
detected_raw_path = marker_match.group(1)
|
||
detected_path = detected_raw_path.strip()
|
||
if not detected_path:
|
||
append_probe_buffer = append_probe_buffer[marker_match.end():]
|
||
continue
|
||
marker_full = marker_match.group(0)
|
||
after_marker_index = append_probe_buffer.find(marker_full) + len(marker_full)
|
||
remainder = append_probe_buffer[after_marker_index:]
|
||
append_probe_buffer = ""
|
||
pending_append = {
|
||
"path": detected_path,
|
||
"tool_call_id": None,
|
||
"buffer": remainder,
|
||
"start_marker": marker_full,
|
||
"end_marker": "<<<END_APPEND>>>",
|
||
"content_start": 0,
|
||
"end_index": None,
|
||
"display_id": None
|
||
}
|
||
if hasattr(web_terminal, "pending_append_request"):
|
||
web_terminal.pending_append_request = {"path": detected_path}
|
||
debug_log(f"直接检测到append起始标记,构建追加缓冲: {detected_path}")
|
||
# 检查是否立即包含结束标记
|
||
if pending_append["buffer"]:
|
||
end_index = pending_append["buffer"].find(pending_append["end_marker"], pending_append["content_start"])
|
||
if end_index != -1:
|
||
pending_append["end_index"] = end_index
|
||
append_break_triggered = True
|
||
debug_log("检测到<<<END_APPEND>>>,即将终止流式输出并写入文件")
|
||
break
|
||
continue
|
||
|
||
if not text_started:
|
||
text_started = True
|
||
text_streaming = True
|
||
sender('text_start', {})
|
||
brief_log("模型输出了内容")
|
||
await asyncio.sleep(0.05)
|
||
|
||
if not pending_append:
|
||
full_response += content
|
||
accumulated_response += content
|
||
text_has_content = True
|
||
emit_time = time.time()
|
||
elapsed = 0.0 if last_text_chunk_time is None else emit_time - last_text_chunk_time
|
||
last_text_chunk_time = emit_time
|
||
text_chunk_index += 1
|
||
log_backend_chunk(
|
||
conversation_id,
|
||
current_iteration,
|
||
text_chunk_index,
|
||
elapsed,
|
||
len(content),
|
||
content[:32]
|
||
)
|
||
sender('text_chunk', {
|
||
'content': content,
|
||
'index': text_chunk_index,
|
||
'elapsed': elapsed
|
||
})
|
||
|
||
# 收集工具调用 - 实时发送准备状态
|
||
if "tool_calls" in delta:
|
||
tool_chunks += 1
|
||
for tc in delta["tool_calls"]:
|
||
found = False
|
||
for existing in tool_calls:
|
||
if existing.get("index") == tc.get("index"):
|
||
if "function" in tc and "arguments" in tc["function"]:
|
||
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}...',
|
||
'intent': intent_value,
|
||
'conversation_id': conversation_id
|
||
})
|
||
debug_log(f" 发送工具准备事件: {tool_name}")
|
||
await asyncio.sleep(0.1)
|
||
|
||
tool_calls.append({
|
||
"id": tool_id,
|
||
"index": tc.get("index"),
|
||
"type": "function",
|
||
"function": {
|
||
"name": tool_name,
|
||
"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}")
|
||
|
||
# 检查是否被停止
|
||
client_stop_info = get_stop_flag(client_sid, username)
|
||
if client_stop_info:
|
||
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
|
||
if stop_requested:
|
||
debug_log("任务在流处理完成后检测到停止状态")
|
||
sender('task_stopped', {
|
||
'message': '命令执行被用户取消',
|
||
'reason': 'user_stop'
|
||
})
|
||
_cancel_pending_tools(tool_calls)
|
||
clear_stop_flag(client_sid, username)
|
||
return
|
||
|
||
# === API响应完成后只计算输出token ===
|
||
if last_usage_payload:
|
||
try:
|
||
web_terminal.context_manager.apply_usage_statistics(last_usage_payload)
|
||
debug_log(
|
||
f"Usage统计: prompt={last_usage_payload.get('prompt_tokens', 0)}, "
|
||
f"completion={last_usage_payload.get('completion_tokens', 0)}, "
|
||
f"total={last_usage_payload.get('total_tokens', 0)}"
|
||
)
|
||
except Exception as e:
|
||
debug_log(f"Usage统计更新失败: {e}")
|
||
else:
|
||
debug_log("未获取到usage字段,跳过token统计更新")
|
||
|
||
|
||
if api_error:
|
||
try:
|
||
debug_log(f"API错误原始数据: {json.dumps(api_error, ensure_ascii=False)}")
|
||
except Exception:
|
||
debug_log(f"API错误原始数据(不可序列化): {repr(api_error)}")
|
||
error_message = ""
|
||
error_status = None
|
||
error_type = None
|
||
error_code = None
|
||
error_text = ""
|
||
request_dump = None
|
||
error_base_url = None
|
||
error_model_id = None
|
||
if isinstance(api_error, dict):
|
||
error_status = api_error.get("status_code")
|
||
error_type = api_error.get("error_type") or api_error.get("type")
|
||
error_code = api_error.get("error_code") or api_error.get("code")
|
||
error_text = api_error.get("error_text") or ""
|
||
error_message = (
|
||
api_error.get("error_message")
|
||
or api_error.get("message")
|
||
or error_text
|
||
or ""
|
||
)
|
||
request_dump = api_error.get("request_dump")
|
||
error_base_url = api_error.get("base_url")
|
||
error_model_id = api_error.get("model_id")
|
||
elif isinstance(api_error, str):
|
||
error_message = api_error
|
||
if not error_message:
|
||
if error_status:
|
||
error_message = f"API 请求失败(HTTP {error_status})"
|
||
else:
|
||
error_message = "API 请求失败"
|
||
# 若命中阿里云配额错误,立即写入状态并切换到官方 API
|
||
try:
|
||
from utils.aliyun_fallback import compute_disabled_until, set_disabled_until
|
||
disabled_until, reason = compute_disabled_until(error_message)
|
||
if disabled_until and reason:
|
||
set_disabled_until(getattr(web_terminal, "model_key", None) or "kimi-k2.5", disabled_until, reason)
|
||
profile = get_model_profile(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||
web_terminal.apply_model_profile(profile)
|
||
except Exception as exc:
|
||
debug_log(f"处理阿里云配额回退失败: {exc}")
|
||
can_retry = (
|
||
api_attempt < max_api_retries
|
||
and not full_response
|
||
and not tool_calls
|
||
and not current_thinking
|
||
and not pending_append
|
||
and not pending_modify
|
||
)
|
||
sender('error', {
|
||
'message': error_message,
|
||
'status_code': error_status,
|
||
'error_type': error_type,
|
||
'error_code': error_code,
|
||
'error_text': error_text,
|
||
'request_dump': request_dump,
|
||
'base_url': error_base_url,
|
||
'model_id': error_model_id,
|
||
'retry': bool(can_retry),
|
||
'retry_in': retry_delay_seconds if can_retry else None,
|
||
'attempt': api_attempt + 1,
|
||
'max_attempts': max_api_retries + 1
|
||
})
|
||
if can_retry:
|
||
try:
|
||
profile = get_model_profile(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||
web_terminal.apply_model_profile(profile)
|
||
except Exception as exc:
|
||
debug_log(f"重试前更新模型配置失败: {exc}")
|
||
cancelled = await _wait_retry_delay(retry_delay_seconds)
|
||
if cancelled:
|
||
return
|
||
continue
|
||
_cancel_pending_tools(tool_calls)
|
||
return
|
||
break
|
||
|
||
# 流结束后的处理
|
||
debug_log(f"\n流结束统计:")
|
||
debug_log(f" 总chunks: {chunk_count}")
|
||
debug_log(f" 思考chunks: {reasoning_chunks}")
|
||
debug_log(f" 内容chunks: {content_chunks}")
|
||
debug_log(f" 工具chunks: {tool_chunks}")
|
||
debug_log(f" 收集到的思考: {len(current_thinking)} 字符")
|
||
debug_log(f" 收集到的正文: {len(full_response)} 字符")
|
||
debug_log(f" 收集到的工具: {len(tool_calls)} 个")
|
||
|
||
if not append_result["handled"] and pending_append:
|
||
append_result = await finalize_pending_append(full_response, True, finish_reason=last_finish_reason)
|
||
if not modify_result["handled"] and pending_modify:
|
||
modify_result = await finalize_pending_modify(full_response, True, finish_reason=last_finish_reason)
|
||
|
||
# 结束未完成的流
|
||
if in_thinking and not thinking_ended:
|
||
sender('thinking_end', {'full_content': current_thinking})
|
||
await asyncio.sleep(0.1)
|
||
|
||
|
||
# 确保text_end事件被发送
|
||
if text_started and text_has_content and not append_result["handled"] and not modify_result["handled"]:
|
||
debug_log(f"发送text_end事件,完整内容长度: {len(full_response)}")
|
||
sender('text_end', {'full_content': full_response})
|
||
await asyncio.sleep(0.1)
|
||
text_streaming = False
|
||
|
||
if full_response.strip():
|
||
debug_log(f"流式文本内容长度: {len(full_response)} 字符")
|
||
|
||
if append_result["handled"]:
|
||
append_metadata = append_result.get("assistant_metadata")
|
||
append_content_text = append_result.get("assistant_content")
|
||
if append_content_text:
|
||
web_terminal.context_manager.add_conversation(
|
||
"assistant",
|
||
append_content_text,
|
||
metadata=append_metadata
|
||
)
|
||
debug_log("💾 增量保存:追加正文快照")
|
||
|
||
payload_info = append_metadata.get("append_payload") if append_metadata else {}
|
||
sender('append_payload', {
|
||
'path': payload_info.get("path") or append_result.get("path"),
|
||
'forced': payload_info.get("forced", False),
|
||
'lines': payload_info.get("lines"),
|
||
'bytes': payload_info.get("bytes"),
|
||
'tool_call_id': payload_info.get("tool_call_id") or append_result.get("tool_call_id"),
|
||
'success': payload_info.get("success", append_result.get("success", False)),
|
||
'conversation_id': conversation_id
|
||
})
|
||
|
||
if append_result["tool_content"]:
|
||
tool_call_id = append_result.get("tool_call_id") or f"append_{int(time.time() * 1000)}"
|
||
system_notice = format_tool_result_notice("append_to_file", tool_call_id, append_result["tool_content"])
|
||
web_terminal.context_manager.add_conversation("system", system_notice)
|
||
append_result["tool_call_id"] = tool_call_id
|
||
debug_log("💾 增量保存:append_to_file 工具结果(system 通知)")
|
||
|
||
finish_reason = append_result.get("finish_reason")
|
||
path_for_prompt = append_result.get("path")
|
||
need_follow_prompt = (
|
||
finish_reason == "length" or
|
||
append_result.get("forced") or
|
||
not append_result.get("success")
|
||
)
|
||
|
||
if need_follow_prompt and path_for_prompt:
|
||
prompt_lines = [
|
||
f"append_to_file 在处理 {path_for_prompt} 时未完成,需要重新发起写入。"
|
||
]
|
||
if finish_reason == "length":
|
||
prompt_lines.append(
|
||
"上一次输出达到系统单次输出上限,已写入的内容已保存。"
|
||
)
|
||
if append_result.get("forced"):
|
||
prompt_lines.append(
|
||
"收到的内容缺少 <<<END_APPEND>>> 标记,系统依据流式结束位置落盘。"
|
||
)
|
||
if not append_result.get("success"):
|
||
prompt_lines.append("系统未能识别有效的追加标记。")
|
||
prompt_lines.append(
|
||
"请再次调用 append_to_file 工具获取新的写入窗口,并在工具调用的输出中遵循以下格式:"
|
||
)
|
||
prompt_lines.append(f"<<<APPEND:{path_for_prompt}>>>")
|
||
prompt_lines.append("...填写剩余正文,如内容已完成可留空...")
|
||
prompt_lines.append("<<<END_APPEND>>>")
|
||
prompt_lines.append("不要在普通回复中粘贴上述标记,必须通过 append_to_file 工具发送。")
|
||
follow_prompt = "\n".join(prompt_lines)
|
||
messages.append({
|
||
"role": "system",
|
||
"content": follow_prompt
|
||
})
|
||
web_terminal.context_manager.add_conversation("system", follow_prompt)
|
||
debug_log("已注入追加任务提示")
|
||
|
||
if append_result["handled"] and append_result.get("forced") and append_result.get("success"):
|
||
mark_force_thinking(web_terminal, reason="append_forced_finish")
|
||
if append_result["handled"] and not append_result.get("success"):
|
||
sender('system_message', {
|
||
'content': f'⚠️ 追加写入失败:{append_result.get("error")}'
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, f'⚠️ 追加写入失败:{append_result.get("error")}')
|
||
mark_force_thinking(web_terminal, reason="append_failed")
|
||
|
||
if modify_result["handled"]:
|
||
modify_metadata = modify_result.get("assistant_metadata")
|
||
modify_content_text = modify_result.get("assistant_content")
|
||
if modify_content_text:
|
||
web_terminal.context_manager.add_conversation(
|
||
"assistant",
|
||
modify_content_text,
|
||
metadata=modify_metadata
|
||
)
|
||
debug_log("💾 增量保存:修改正文快照")
|
||
|
||
payload_info = modify_metadata.get("modify_payload") if modify_metadata else {}
|
||
sender('modify_payload', {
|
||
'path': payload_info.get("path") or modify_result.get("path"),
|
||
'total': payload_info.get("total_blocks") or modify_result.get("total_blocks"),
|
||
'completed': payload_info.get("completed") or modify_result.get("completed_blocks"),
|
||
'failed': payload_info.get("failed") or modify_result.get("failed_blocks"),
|
||
'forced': payload_info.get("forced", modify_result.get("forced", False)),
|
||
'success': modify_result.get("success", False),
|
||
'conversation_id': conversation_id
|
||
})
|
||
|
||
if modify_result["tool_content"]:
|
||
tool_call_id = modify_result.get("tool_call_id") or f"modify_{int(time.time() * 1000)}"
|
||
system_notice = format_tool_result_notice("modify_file", tool_call_id, modify_result["tool_content"])
|
||
web_terminal.context_manager.add_conversation("system", system_notice)
|
||
modify_result["tool_call_id"] = tool_call_id
|
||
debug_log("💾 增量保存:modify_file 工具结果(system 通知)")
|
||
|
||
path_for_prompt = modify_result.get("path")
|
||
failed_blocks = modify_result.get("failed_blocks") or []
|
||
need_follow_prompt = modify_result.get("forced") or bool(failed_blocks)
|
||
|
||
if need_follow_prompt and path_for_prompt:
|
||
prompt_lines = [
|
||
f"modify_file 在处理 {path_for_prompt} 时未完成,需要重新发起补丁。"
|
||
]
|
||
if modify_result.get("forced"):
|
||
prompt_lines.append(
|
||
"刚才的内容缺少 <<<END_MODIFY>>> 标记,系统仅应用了已识别的部分。"
|
||
)
|
||
if failed_blocks:
|
||
failed_text = "、".join(str(idx) for idx in failed_blocks)
|
||
prompt_lines.append(f"以下补丁未成功:第 {failed_text} 处。")
|
||
prompt_lines.append(
|
||
"请再次调用 modify_file 工具,并在新的工具调用中按以下模板提供完整补丁:"
|
||
)
|
||
prompt_lines.append(f"<<<MODIFY:{path_for_prompt}>>>")
|
||
prompt_lines.append("[replace:序号]")
|
||
prompt_lines.append("<<OLD>>")
|
||
prompt_lines.append("...原文(必须逐字匹配,包含全部缩进、空格和换行)...")
|
||
prompt_lines.append("<<END>>")
|
||
prompt_lines.append("<<NEW>>")
|
||
prompt_lines.append("...新内容,可留空表示清空,注意保持结构完整...")
|
||
prompt_lines.append("<<END>>")
|
||
prompt_lines.append("[/replace]")
|
||
prompt_lines.append("<<<END_MODIFY>>>")
|
||
prompt_lines.append("请勿在普通回复中直接粘贴补丁,必须通过 modify_file 工具发送。")
|
||
follow_prompt = "\n".join(prompt_lines)
|
||
messages.append({
|
||
"role": "system",
|
||
"content": follow_prompt
|
||
})
|
||
web_terminal.context_manager.add_conversation("system", follow_prompt)
|
||
debug_log("已注入修改任务提示")
|
||
|
||
if modify_result["handled"] and modify_result.get("failed_blocks"):
|
||
mark_force_thinking(web_terminal, reason="modify_partial_failure")
|
||
if modify_result["handled"] and modify_result.get("forced") and modify_result.get("success"):
|
||
mark_force_thinking(web_terminal, reason="modify_forced_finish")
|
||
if modify_result["handled"] and not modify_result.get("success"):
|
||
error_message = modify_result.get("summary_message") or modify_result.get("error") or "修改操作未成功,请根据提示重新执行。"
|
||
sender('system_message', {
|
||
'content': f'⚠️ 修改操作存在未完成的内容:{error_message}'
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, f'⚠️ 修改操作存在未完成的内容:{error_message}')
|
||
mark_force_thinking(web_terminal, reason="modify_failed")
|
||
|
||
if web_terminal.api_client.last_call_used_thinking and current_thinking:
|
||
web_terminal.api_client.current_task_thinking = current_thinking or ""
|
||
if web_terminal.api_client.current_task_first_call:
|
||
web_terminal.api_client.current_task_first_call = False
|
||
update_thinking_after_call(web_terminal)
|
||
|
||
# 检测是否有格式错误的工具调用
|
||
if not tool_calls and full_response and AUTO_FIX_TOOL_CALL and not append_result["handled"] and not modify_result["handled"]:
|
||
if detect_malformed_tool_call(full_response):
|
||
auto_fix_attempts += 1
|
||
|
||
if auto_fix_attempts <= AUTO_FIX_MAX_ATTEMPTS:
|
||
debug_log(f"检测到格式错误的工具调用,尝试自动修复 (尝试 {auto_fix_attempts}/{AUTO_FIX_MAX_ATTEMPTS})")
|
||
|
||
fix_message = "你使用了错误的格式输出工具调用。请使用正确的工具调用格式而不是直接输出JSON。根据当前进度继续执行任务。"
|
||
|
||
sender('system_message', {
|
||
'content': f'⚠️ 自动修复: {fix_message}'
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, f'⚠️ 自动修复: {fix_message}')
|
||
|
||
messages.append({
|
||
"role": "user",
|
||
"content": fix_message
|
||
})
|
||
|
||
await asyncio.sleep(1)
|
||
continue
|
||
else:
|
||
debug_log(f"自动修复尝试已达上限 ({AUTO_FIX_MAX_ATTEMPTS})")
|
||
sender('system_message', {
|
||
'content': f'⌘ 工具调用格式错误,自动修复失败。请手动检查并重试。'
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, '⌘ 工具调用格式错误,自动修复失败。请手动检查并重试。')
|
||
break
|
||
|
||
# 构建助手消息(用于API继续对话)
|
||
assistant_content_parts = []
|
||
|
||
if full_response:
|
||
assistant_content_parts.append(full_response)
|
||
elif append_result["handled"] and append_result["assistant_content"]:
|
||
assistant_content_parts.append(append_result["assistant_content"])
|
||
elif modify_result["handled"] and modify_result.get("assistant_content"):
|
||
assistant_content_parts.append(modify_result["assistant_content"])
|
||
|
||
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else ""
|
||
|
||
# 添加到消息历史(用于API继续对话,不保存到文件)
|
||
assistant_message = {
|
||
"role": "assistant",
|
||
"content": assistant_content,
|
||
"tool_calls": tool_calls
|
||
}
|
||
if current_thinking:
|
||
assistant_message["reasoning_content"] = current_thinking
|
||
|
||
messages.append(assistant_message)
|
||
if assistant_content or current_thinking or tool_calls:
|
||
web_terminal.context_manager.add_conversation(
|
||
"assistant",
|
||
assistant_content,
|
||
tool_calls=tool_calls if tool_calls else None,
|
||
reasoning_content=current_thinking or None
|
||
)
|
||
|
||
# 为下一轮迭代重置流状态标志,但保留 full_response 供上面保存使用
|
||
text_streaming = False
|
||
text_started = False
|
||
text_has_content = False
|
||
full_response = ""
|
||
|
||
if append_result["handled"] and append_result.get("tool_content"):
|
||
tool_call_id = append_result.get("tool_call_id") or f"append_{int(time.time() * 1000)}"
|
||
system_notice = format_tool_result_notice("append_to_file", tool_call_id, append_result["tool_content"])
|
||
messages.append({
|
||
"role": "system",
|
||
"content": system_notice
|
||
})
|
||
append_result["tool_call_id"] = tool_call_id
|
||
debug_log("已将 append_to_file 工具结果以 system 形式追加到对话上下文")
|
||
if modify_result["handled"] and modify_result.get("tool_content"):
|
||
tool_call_id = modify_result.get("tool_call_id") or f"modify_{int(time.time() * 1000)}"
|
||
system_notice = format_tool_result_notice("modify_file", tool_call_id, modify_result["tool_content"])
|
||
messages.append({
|
||
"role": "system",
|
||
"content": system_notice
|
||
})
|
||
modify_result["tool_call_id"] = tool_call_id
|
||
debug_log("已将 modify_file 工具结果以 system 形式追加到对话上下文")
|
||
|
||
force_continue = append_result["handled"] or modify_result["handled"]
|
||
if force_continue:
|
||
if append_result["handled"]:
|
||
debug_log("append_to_file 已处理,继续下一轮以让模型返回确认回复")
|
||
elif modify_result["handled"]:
|
||
debug_log("modify_file 已处理,继续下一轮以让模型返回确认回复")
|
||
else:
|
||
debug_log("补丁处理完成,继续下一轮以获取模型回复")
|
||
continue
|
||
|
||
if not tool_calls:
|
||
debug_log("没有工具调用,结束迭代")
|
||
break
|
||
|
||
# 检查连续相同工具调用
|
||
for tc in tool_calls:
|
||
tool_name = tc["function"]["name"]
|
||
|
||
if tool_name == last_tool_name:
|
||
consecutive_same_tool[tool_name] += 1
|
||
|
||
if (
|
||
MAX_CONSECUTIVE_SAME_TOOL is not None
|
||
and consecutive_same_tool[tool_name] >= MAX_CONSECUTIVE_SAME_TOOL
|
||
):
|
||
debug_log(f"警告: 连续调用相同工具 {tool_name} 已达 {MAX_CONSECUTIVE_SAME_TOOL} 次")
|
||
sender('system_message', {
|
||
'content': f'⚠️ 检测到重复调用 {tool_name} 工具 {MAX_CONSECUTIVE_SAME_TOOL} 次,可能存在循环。'
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, f'⚠️ 检测到重复调用 {tool_name} 工具 {MAX_CONSECUTIVE_SAME_TOOL} 次,可能存在循环。')
|
||
|
||
if consecutive_same_tool[tool_name] >= MAX_CONSECUTIVE_SAME_TOOL + 2:
|
||
debug_log(f"终止: 工具 {tool_name} 调用次数过多")
|
||
sender('system_message', {
|
||
'content': f'⌘ 工具 {tool_name} 重复调用过多,任务终止。'
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, f'⌘ 工具 {tool_name} 重复调用过多,任务终止。')
|
||
break
|
||
else:
|
||
consecutive_same_tool.clear()
|
||
consecutive_same_tool[tool_name] = 1
|
||
|
||
last_tool_name = tool_name
|
||
# 更新统计
|
||
total_tool_calls += len(tool_calls)
|
||
|
||
# 执行每个工具
|
||
for tool_call in tool_calls:
|
||
# 检查停止标志
|
||
client_stop_info = get_stop_flag(client_sid, username)
|
||
if client_stop_info:
|
||
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
|
||
if stop_requested:
|
||
debug_log("在工具调用过程中检测到停止状态")
|
||
tool_call_id = tool_call.get("id")
|
||
function_name = tool_call.get("function", {}).get("name")
|
||
# 通知前端该工具已被取消,避免界面卡住
|
||
sender('update_action', {
|
||
'preparing_id': tool_call_id,
|
||
'status': 'cancelled',
|
||
'result': {
|
||
"success": False,
|
||
"status": "cancelled",
|
||
"message": "命令执行被用户取消",
|
||
"tool": function_name
|
||
}
|
||
})
|
||
# 在消息列表中记录取消结果,防止重新加载时仍显示运行中
|
||
if tool_call_id:
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call_id,
|
||
"name": function_name,
|
||
"content": "命令执行被用户取消",
|
||
"metadata": {"status": "cancelled"}
|
||
})
|
||
sender('task_stopped', {
|
||
'message': '命令执行被用户取消',
|
||
'reason': 'user_stop'
|
||
})
|
||
clear_stop_flag(client_sid, username)
|
||
return
|
||
|
||
# 工具调用间隔控制
|
||
current_time = time.time()
|
||
if last_tool_call_time > 0:
|
||
elapsed = current_time - last_tool_call_time
|
||
if elapsed < TOOL_CALL_COOLDOWN:
|
||
await asyncio.sleep(TOOL_CALL_COOLDOWN - elapsed)
|
||
last_tool_call_time = time.time()
|
||
|
||
function_name = tool_call["function"]["name"]
|
||
arguments_str = tool_call["function"]["arguments"]
|
||
tool_call_id = tool_call["id"]
|
||
|
||
|
||
debug_log(f"准备解析JSON,工具: {function_name}, 参数长度: {len(arguments_str)}")
|
||
debug_log(f"JSON参数前200字符: {arguments_str[:200]}")
|
||
debug_log(f"JSON参数后200字符: {arguments_str[-200:]}")
|
||
|
||
# 使用改进的参数解析方法
|
||
if hasattr(web_terminal, 'api_client') and hasattr(web_terminal.api_client, '_safe_tool_arguments_parse'):
|
||
success, arguments, error_msg = web_terminal.api_client._safe_tool_arguments_parse(arguments_str, function_name)
|
||
if not success:
|
||
debug_log(f"安全解析失败: {error_msg}")
|
||
error_text = f'工具参数解析失败: {error_msg}'
|
||
error_payload = {
|
||
"success": False,
|
||
"error": error_text,
|
||
"error_type": "parameter_format_error",
|
||
"tool_name": function_name,
|
||
"tool_call_id": tool_call_id,
|
||
"message": error_text
|
||
}
|
||
sender('error', {'message': error_text})
|
||
sender('update_action', {
|
||
'preparing_id': tool_call_id,
|
||
'status': 'completed',
|
||
'result': error_payload,
|
||
'message': error_text
|
||
})
|
||
error_content = json.dumps(error_payload, ensure_ascii=False)
|
||
web_terminal.context_manager.add_conversation(
|
||
"tool",
|
||
error_content,
|
||
tool_call_id=tool_call_id,
|
||
name=function_name
|
||
)
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call_id,
|
||
"name": function_name,
|
||
"content": error_content
|
||
})
|
||
continue
|
||
debug_log(f"使用安全解析成功,参数键: {list(arguments.keys())}")
|
||
else:
|
||
# 回退到带有基本修复逻辑的解析
|
||
try:
|
||
arguments = json.loads(arguments_str) if arguments_str.strip() else {}
|
||
debug_log(f"直接JSON解析成功,参数键: {list(arguments.keys())}")
|
||
except json.JSONDecodeError as e:
|
||
debug_log(f"原始JSON解析失败: {e}")
|
||
# 尝试基本的JSON修复
|
||
repaired_str = arguments_str.strip()
|
||
repair_attempts = []
|
||
|
||
# 修复1: 未闭合字符串
|
||
if repaired_str.count('"') % 2 == 1:
|
||
repaired_str += '"'
|
||
repair_attempts.append("添加闭合引号")
|
||
|
||
# 修复2: 未闭合JSON对象
|
||
if repaired_str.startswith('{') and not repaired_str.rstrip().endswith('}'):
|
||
repaired_str = repaired_str.rstrip() + '}'
|
||
repair_attempts.append("添加闭合括号")
|
||
|
||
# 修复3: 截断的JSON(移除不完整的最后一个键值对)
|
||
if not repair_attempts: # 如果前面的修复都没用上
|
||
last_comma = repaired_str.rfind(',')
|
||
if last_comma > 0:
|
||
repaired_str = repaired_str[:last_comma] + '}'
|
||
repair_attempts.append("移除不完整的键值对")
|
||
|
||
# 尝试解析修复后的JSON
|
||
try:
|
||
arguments = json.loads(repaired_str)
|
||
debug_log(f"JSON修复成功: {', '.join(repair_attempts)}")
|
||
debug_log(f"修复后参数键: {list(arguments.keys())}")
|
||
except json.JSONDecodeError as repair_error:
|
||
debug_log(f"JSON修复也失败: {repair_error}")
|
||
debug_log(f"修复尝试: {repair_attempts}")
|
||
debug_log(f"修复后内容前100字符: {repaired_str[:100]}")
|
||
error_text = f'工具参数解析失败: {e}'
|
||
error_payload = {
|
||
"success": False,
|
||
"error": error_text,
|
||
"error_type": "parameter_format_error",
|
||
"tool_name": function_name,
|
||
"tool_call_id": tool_call_id,
|
||
"message": error_text
|
||
}
|
||
sender('error', {'message': error_text})
|
||
sender('update_action', {
|
||
'preparing_id': tool_call_id,
|
||
'status': 'completed',
|
||
'result': error_payload,
|
||
'message': error_text
|
||
})
|
||
error_content = json.dumps(error_payload, ensure_ascii=False)
|
||
web_terminal.context_manager.add_conversation(
|
||
"tool",
|
||
error_content,
|
||
tool_call_id=tool_call_id,
|
||
name=function_name
|
||
)
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call_id,
|
||
"name": function_name,
|
||
"content": error_content
|
||
})
|
||
continue
|
||
|
||
debug_log(f"执行工具: {function_name} (ID: {tool_call_id})")
|
||
|
||
# 发送工具开始事件
|
||
tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}"
|
||
monitor_snapshot = None
|
||
snapshot_path = None
|
||
memory_snapshot_type = None
|
||
if function_name in MONITOR_FILE_TOOLS:
|
||
snapshot_path = resolve_monitor_path(arguments)
|
||
monitor_snapshot = capture_monitor_snapshot(web_terminal.file_manager, snapshot_path, MONITOR_SNAPSHOT_CHAR_LIMIT, debug_log)
|
||
if monitor_snapshot:
|
||
cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot)
|
||
elif function_name in MONITOR_MEMORY_TOOLS:
|
||
memory_snapshot_type = (arguments.get('memory_type') or 'main').lower()
|
||
before_entries = None
|
||
try:
|
||
before_entries = resolve_monitor_memory(web_terminal.memory_manager._read_entries(memory_snapshot_type), MONITOR_MEMORY_ENTRY_LIMIT)
|
||
except Exception as exc:
|
||
debug_log(f"[MonitorSnapshot] 读取记忆失败: {memory_snapshot_type} ({exc})")
|
||
if before_entries is not None:
|
||
monitor_snapshot = {
|
||
'memory_type': memory_snapshot_type,
|
||
'entries': before_entries
|
||
}
|
||
cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot)
|
||
|
||
sender('tool_start', {
|
||
'id': tool_display_id,
|
||
'name': function_name,
|
||
'arguments': arguments,
|
||
'preparing_id': tool_call_id,
|
||
'monitor_snapshot': monitor_snapshot,
|
||
'conversation_id': conversation_id
|
||
})
|
||
brief_log(f"调用了工具: {function_name}")
|
||
|
||
await asyncio.sleep(0.3)
|
||
start_time = time.time()
|
||
|
||
# 执行工具
|
||
tool_result = await web_terminal.handle_tool_call(function_name, arguments)
|
||
debug_log(f"工具结果: {tool_result[:200]}...")
|
||
|
||
execution_time = time.time() - start_time
|
||
if execution_time < 1.5:
|
||
await asyncio.sleep(1.5 - execution_time)
|
||
|
||
# 更新工具状态
|
||
result_data = {}
|
||
try:
|
||
result_data = json.loads(tool_result)
|
||
except:
|
||
result_data = {'output': tool_result}
|
||
tool_failed = detect_tool_failure(result_data)
|
||
|
||
action_status = 'completed'
|
||
action_message = None
|
||
awaiting_flag = False
|
||
|
||
if function_name in {"write_file", "edit_file"}:
|
||
diff_path = result_data.get("path") or arguments.get("file_path")
|
||
summary = result_data.get("summary") or result_data.get("message")
|
||
if summary:
|
||
action_message = summary
|
||
debug_log(f"{function_name} 执行完成: {summary or '无摘要'}")
|
||
|
||
if function_name == "wait_sub_agent":
|
||
system_msg = result_data.get("system_message")
|
||
if system_msg:
|
||
messages.append({
|
||
"role": "system",
|
||
"content": system_msg
|
||
})
|
||
sender('system_message', {
|
||
'content': system_msg,
|
||
'inline': False
|
||
})
|
||
maybe_mark_failure_from_message(web_terminal, system_msg)
|
||
monitor_snapshot_after = None
|
||
if function_name in MONITOR_FILE_TOOLS:
|
||
result_path = None
|
||
if isinstance(result_data, dict):
|
||
result_path = resolve_monitor_path(result_data)
|
||
if not result_path:
|
||
candidate_path = result_data.get('path')
|
||
if isinstance(candidate_path, str) and candidate_path.strip():
|
||
result_path = candidate_path.strip()
|
||
if not result_path:
|
||
result_path = resolve_monitor_path(arguments, snapshot_path) or snapshot_path
|
||
monitor_snapshot_after = capture_monitor_snapshot(web_terminal.file_manager, result_path, MONITOR_SNAPSHOT_CHAR_LIMIT, debug_log)
|
||
elif function_name in MONITOR_MEMORY_TOOLS:
|
||
memory_after_type = str(
|
||
arguments.get('memory_type')
|
||
or (isinstance(result_data, dict) and result_data.get('memory_type'))
|
||
or memory_snapshot_type
|
||
or 'main'
|
||
).lower()
|
||
after_entries = None
|
||
try:
|
||
after_entries = resolve_monitor_memory(web_terminal.memory_manager._read_entries(memory_after_type), MONITOR_MEMORY_ENTRY_LIMIT)
|
||
except Exception as exc:
|
||
debug_log(f"[MonitorSnapshot] 读取记忆失败(after): {memory_after_type} ({exc})")
|
||
if after_entries is not None:
|
||
monitor_snapshot_after = {
|
||
'memory_type': memory_after_type,
|
||
'entries': after_entries
|
||
}
|
||
|
||
update_payload = {
|
||
'id': tool_display_id,
|
||
'status': action_status,
|
||
'result': result_data,
|
||
'preparing_id': tool_call_id,
|
||
'conversation_id': conversation_id
|
||
}
|
||
if action_message:
|
||
update_payload['message'] = action_message
|
||
if awaiting_flag:
|
||
update_payload['awaiting_content'] = True
|
||
if monitor_snapshot_after:
|
||
update_payload['monitor_snapshot_after'] = monitor_snapshot_after
|
||
cache_monitor_snapshot(tool_display_id, 'after', monitor_snapshot_after)
|
||
|
||
sender('update_action', update_payload)
|
||
|
||
if function_name in ['create_file', 'delete_file', 'rename_file', 'create_folder']:
|
||
if not web_terminal.context_manager._is_host_mode_without_safety():
|
||
structure = web_terminal.context_manager.get_project_structure()
|
||
sender('file_tree_update', structure)
|
||
|
||
# ===== 增量保存:立即保存工具结果 =====
|
||
metadata_payload = None
|
||
tool_images = None
|
||
tool_videos = None
|
||
if isinstance(result_data, dict):
|
||
# 特殊处理 web_search:保留可供前端渲染的精简结构,以便历史记录复现搜索结果
|
||
if function_name == "web_search":
|
||
try:
|
||
tool_result_content = json.dumps(compact_web_search_result(result_data), ensure_ascii=False)
|
||
except Exception:
|
||
tool_result_content = tool_result
|
||
else:
|
||
tool_result_content = format_tool_result_for_context(function_name, result_data, tool_result)
|
||
metadata_payload = {"tool_payload": result_data}
|
||
else:
|
||
tool_result_content = tool_result
|
||
tool_message_content = tool_result_content
|
||
|
||
# view_image: 将图片直接附加到 tool 结果中(不再插入 user 消息)
|
||
if function_name == "view_image" and getattr(web_terminal, "pending_image_view", None):
|
||
inj = web_terminal.pending_image_view
|
||
web_terminal.pending_image_view = None
|
||
if (
|
||
not tool_failed
|
||
and isinstance(result_data, dict)
|
||
and result_data.get("success") is not False
|
||
):
|
||
img_path = inj.get("path") if isinstance(inj, dict) else None
|
||
if img_path:
|
||
text_part = tool_result_content if isinstance(tool_result_content, str) else ""
|
||
tool_message_content = web_terminal.context_manager._build_content_with_images(
|
||
text_part,
|
||
[img_path]
|
||
)
|
||
tool_images = [img_path]
|
||
if metadata_payload is None:
|
||
metadata_payload = {}
|
||
metadata_payload["tool_image_path"] = img_path
|
||
sender('system_message', {
|
||
'content': f'系统已按模型请求将图片附加到工具结果: {img_path}'
|
||
})
|
||
|
||
# view_video: 将视频直接附加到 tool 结果中(不再插入 user 消息)
|
||
if function_name == "view_video" and getattr(web_terminal, "pending_video_view", None):
|
||
inj = web_terminal.pending_video_view
|
||
web_terminal.pending_video_view = None
|
||
if (
|
||
not tool_failed
|
||
and isinstance(result_data, dict)
|
||
and result_data.get("success") is not False
|
||
):
|
||
video_path = inj.get("path") if isinstance(inj, dict) else None
|
||
if video_path:
|
||
text_part = tool_result_content if isinstance(tool_result_content, str) else ""
|
||
video_payload = [video_path]
|
||
tool_message_content = web_terminal.context_manager._build_content_with_images(
|
||
text_part,
|
||
[],
|
||
video_payload
|
||
)
|
||
tool_videos = [video_path]
|
||
if metadata_payload is None:
|
||
metadata_payload = {}
|
||
metadata_payload["tool_video_path"] = video_path
|
||
sender('system_message', {
|
||
'content': f'系统已按模型请求将视频附加到工具结果: {video_path}'
|
||
})
|
||
|
||
# 立即保存工具结果
|
||
web_terminal.context_manager.add_conversation(
|
||
"tool",
|
||
tool_result_content,
|
||
tool_call_id=tool_call_id,
|
||
name=function_name,
|
||
metadata=metadata_payload,
|
||
images=tool_images,
|
||
videos=tool_videos
|
||
)
|
||
debug_log(f"💾 增量保存:工具结果 {function_name}")
|
||
system_message = result_data.get("system_message") if isinstance(result_data, dict) else None
|
||
if system_message:
|
||
web_terminal._record_sub_agent_message(system_message, result_data.get("task_id"), inline=False)
|
||
maybe_mark_failure_from_message(web_terminal, system_message)
|
||
|
||
# 添加到消息历史(用于API继续对话)
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call_id,
|
||
"name": function_name,
|
||
"content": tool_message_content
|
||
})
|
||
|
||
if function_name not in {'write_file', 'edit_file'}:
|
||
await process_sub_agent_updates(messages, inline=True, after_tool_call_id=tool_call_id)
|
||
|
||
await asyncio.sleep(0.2)
|
||
|
||
if tool_failed:
|
||
mark_force_thinking(web_terminal, reason=f"{function_name}_failed")
|
||
|
||
# 标记不再是第一次迭代
|
||
is_first_iteration = False
|
||
|
||
|
||
# 最终统计
|
||
debug_log(f"\n{'='*40}")
|
||
debug_log(f"任务完成统计:")
|
||
debug_log(f" 总迭代次数: {total_iterations}")
|
||
debug_log(f" 总工具调用: {total_tool_calls}")
|
||
debug_log(f" 自动修复尝试: {auto_fix_attempts}")
|
||
debug_log(f" 累积响应: {len(accumulated_response)} 字符")
|
||
debug_log(f"{'='*40}\n")
|
||
|
||
# 发送完成事件
|
||
sender('task_complete', {
|
||
'total_iterations': total_iterations,
|
||
'total_tool_calls': total_tool_calls,
|
||
'auto_fix_attempts': auto_fix_attempts
|
||
})
|