1549 lines
62 KiB
Python
1549 lines
62 KiB
Python
from __future__ import annotations
|
||
import sys, os
|
||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||
if PROJECT_ROOT not in sys.path:
|
||
sys.path.insert(0, PROJECT_ROOT)
|
||
|
||
import asyncio, json, time, re, os
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from collections import defaultdict, Counter, deque
|
||
from typing import Dict, Any, Optional, List, Tuple
|
||
|
||
from flask import Blueprint, request, jsonify, session
|
||
from flask_socketio import emit
|
||
from werkzeug.utils import secure_filename
|
||
import zipfile
|
||
|
||
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 modules.sub_agent_manager import TERMINAL_STATUSES
|
||
from modules.versioning_manager import ConversationVersioningManager, VersioningError
|
||
from core.web_terminal import WebTerminal
|
||
from utils.tool_result_formatter import format_tool_result_for_context
|
||
from utils.conversation_manager import ConversationManager
|
||
from utils.api_client import DeepSeekClient
|
||
|
||
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
|
||
from .context import with_terminal, get_terminal_for_sid, 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,
|
||
_sanitize_filename_component,
|
||
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 .extensions import socketio
|
||
from .state import (
|
||
RECENT_UPLOAD_EVENT_LIMIT,
|
||
RECENT_UPLOAD_FEED_LIMIT,
|
||
user_manager,
|
||
container_manager,
|
||
get_last_active_ts,
|
||
)
|
||
from .usage import record_user_activity
|
||
from .conversation_stats import (
|
||
build_admin_dashboard_snapshot,
|
||
compute_workspace_storage,
|
||
collect_user_token_statistics,
|
||
collect_upload_events,
|
||
summarize_upload_events,
|
||
)
|
||
from .deep_compression import run_deep_compression
|
||
|
||
conversation_bp = Blueprint('conversation', __name__)
|
||
|
||
|
||
def _terminate_running_sub_agents(terminal: WebTerminal, reason: str = "") -> int:
|
||
"""切换/新建对话时,强制终止当前对话仍在运行的子智能体,并记录系统消息。"""
|
||
manager = getattr(terminal, "sub_agent_manager", None)
|
||
if not manager:
|
||
return 0
|
||
current_conv_id = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
|
||
if not current_conv_id:
|
||
return 0
|
||
running_tasks = [
|
||
task for task in manager.tasks.values()
|
||
if task.get("status") not in TERMINAL_STATUSES.union({"terminated"})
|
||
and task.get("run_in_background")
|
||
and task.get("conversation_id") == current_conv_id
|
||
]
|
||
if not running_tasks:
|
||
return 0
|
||
stopped_count = 0
|
||
for task in running_tasks:
|
||
task_id = task.get("task_id")
|
||
manager.terminate_sub_agent(task_id=task_id)
|
||
stopped_count += 1
|
||
return stopped_count
|
||
|
||
|
||
def _cancel_running_tasks(username: str, workspace_id: str, timeout_seconds: float = 4.0) -> Tuple[int, bool]:
|
||
"""取消当前工作区运行中的主任务,并等待其停止,避免切换对话后串写。"""
|
||
try:
|
||
from .tasks import task_manager
|
||
except Exception as exc:
|
||
debug_log(f"[TaskCancel] 导入 task_manager 失败: {exc}")
|
||
return 0, True
|
||
|
||
active_statuses = {"pending", "running", "cancel_requested"}
|
||
|
||
def _active_tasks():
|
||
try:
|
||
recs = task_manager.list_tasks(username, workspace_id)
|
||
except Exception:
|
||
recs = task_manager.list_tasks(username)
|
||
return [rec for rec in recs if getattr(rec, "status", None) in active_statuses]
|
||
|
||
running = _active_tasks()
|
||
if not running:
|
||
return 0, True
|
||
|
||
canceled = 0
|
||
for rec in running:
|
||
task_id = getattr(rec, "task_id", None)
|
||
if task_id and task_manager.cancel_task(username, task_id):
|
||
canceled += 1
|
||
|
||
deadline = time.time() + max(timeout_seconds, 0.5)
|
||
while time.time() < deadline:
|
||
if not _active_tasks():
|
||
return canceled, True
|
||
time.sleep(0.1)
|
||
|
||
return canceled, False
|
||
|
||
|
||
def _normalize_conv_id(conversation_id: str) -> str:
|
||
conv = (conversation_id or "").strip()
|
||
if not conv:
|
||
return conv
|
||
return conv if conv.startswith("conv_") else f"conv_{conv}"
|
||
|
||
|
||
def _is_host_mode_request(username: str) -> bool:
|
||
return bool(session.get("host_mode")) or (username == "host")
|
||
|
||
|
||
def _get_conv_versioning_manager(workspace: UserWorkspace, conversation_id: str) -> ConversationVersioningManager:
|
||
normalized = _normalize_conv_id(conversation_id)
|
||
return ConversationVersioningManager(
|
||
project_path=workspace.project_path,
|
||
data_dir=workspace.data_dir,
|
||
conversation_id=normalized,
|
||
)
|
||
|
||
|
||
def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]:
|
||
normalized = _normalize_conv_id(conversation_id)
|
||
data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {}
|
||
meta = data.get("metadata") or {}
|
||
versioning = meta.get("versioning") or {}
|
||
if not isinstance(versioning, dict):
|
||
versioning = {}
|
||
enabled = bool(versioning.get("enabled", False))
|
||
mode = "overwrite"
|
||
return {"enabled": enabled, "mode": mode, "conversation_id": normalized, "metadata": meta}
|
||
|
||
|
||
def _update_conversation_versioning_meta(
|
||
terminal: WebTerminal,
|
||
conversation_id: str,
|
||
*,
|
||
enabled: bool,
|
||
mode: str,
|
||
last_commit: Optional[str] = None,
|
||
last_input_seq: Optional[int] = None,
|
||
) -> bool:
|
||
normalized = _normalize_conv_id(conversation_id)
|
||
payload: Dict[str, Any] = {
|
||
"versioning": {
|
||
"enabled": bool(enabled),
|
||
"mode": "overwrite",
|
||
"updated_at": datetime.now().isoformat(),
|
||
}
|
||
}
|
||
if last_commit is not None:
|
||
payload["versioning"]["last_commit"] = last_commit
|
||
if last_input_seq is not None:
|
||
payload["versioning"]["last_input_seq"] = int(last_input_seq)
|
||
return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload)
|
||
|
||
|
||
# === 背景生成对话标题(从 app_legacy 拆分) ===
|
||
@conversation_bp.route('/api/conversations', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""获取对话列表"""
|
||
try:
|
||
# 获取查询参数
|
||
limit = request.args.get('limit', 20, type=int)
|
||
offset = request.args.get('offset', 0, type=int)
|
||
|
||
# 限制参数范围
|
||
limit = max(1, min(limit, 10000)) # 限制在1-10000之间
|
||
offset = max(0, offset)
|
||
|
||
result = terminal.get_conversations_list(limit=limit, offset=offset)
|
||
|
||
if result["success"]:
|
||
return jsonify({
|
||
"success": True,
|
||
"data": result["data"]
|
||
})
|
||
else:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": result.get("error", "Unknown error"),
|
||
"message": result.get("message", "获取对话列表失败")
|
||
}), 500
|
||
|
||
except Exception as e:
|
||
print(f"[API] 获取对话列表错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "获取对话列表时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""创建新对话"""
|
||
try:
|
||
data = request.get_json() or {}
|
||
# 前端现在期望"新建对话"回到用户配置的默认模型/模式,
|
||
# 只有当客户端显式要求保留当前模式时才使用传入值。
|
||
preserve_mode = bool(data.get('preserve_mode'))
|
||
thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None
|
||
run_mode = data.get('mode') if preserve_mode and 'mode' in data else None
|
||
|
||
workspace_id = session.get("workspace_id") or "default"
|
||
canceled_count, settled = _cancel_running_tasks(username=username, workspace_id=workspace_id)
|
||
if canceled_count:
|
||
debug_log(f"[TaskCancel] 创建新对话前取消任务: {canceled_count}")
|
||
if not settled:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "任务正在停止中,请稍后再试",
|
||
"message": "检测到后台任务仍在停止,请稍后再创建新对话。"
|
||
}), 409
|
||
|
||
_terminate_running_sub_agents(terminal, reason="用户创建新对话")
|
||
result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode)
|
||
|
||
if result["success"]:
|
||
session['run_mode'] = terminal.run_mode
|
||
session['thinking_mode'] = terminal.thinking_mode
|
||
# 广播对话列表更新事件
|
||
socketio.emit('conversation_list_update', {
|
||
'action': 'created',
|
||
'conversation_id': result["conversation_id"]
|
||
}, room=f"user_{username}")
|
||
|
||
# 广播当前对话切换事件
|
||
socketio.emit('conversation_changed', {
|
||
'conversation_id': result["conversation_id"],
|
||
'title': "新对话"
|
||
}, room=f"user_{username}")
|
||
|
||
return jsonify(result), 201
|
||
else:
|
||
return jsonify(result), 500
|
||
|
||
except Exception as e:
|
||
print(f"[API] 创建对话错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "创建对话时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversation_info(terminal: WebTerminal, workspace: UserWorkspace, username: str, conversation_id):
|
||
"""获取特定对话信息"""
|
||
try:
|
||
# 通过ConversationManager直接获取对话数据
|
||
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
|
||
|
||
if conversation_data:
|
||
# 提取关键信息,不返回完整消息内容(避免数据量过大)
|
||
info = {
|
||
"id": conversation_data["id"],
|
||
"title": conversation_data["title"],
|
||
"created_at": conversation_data["created_at"],
|
||
"updated_at": conversation_data["updated_at"],
|
||
"metadata": conversation_data["metadata"],
|
||
"messages_count": len(conversation_data.get("messages", []))
|
||
}
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": info
|
||
})
|
||
else:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "Conversation not found",
|
||
"message": f"对话 {conversation_id} 不存在"
|
||
}), 404
|
||
|
||
except Exception as e:
|
||
print(f"[API] 获取对话信息错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "获取对话信息时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/load', methods=['PUT'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""加载特定对话"""
|
||
try:
|
||
current_id = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
|
||
if current_id and current_id != conversation_id:
|
||
workspace_id = session.get("workspace_id") or "default"
|
||
canceled_count, settled = _cancel_running_tasks(username=username, workspace_id=workspace_id)
|
||
if canceled_count:
|
||
debug_log(f"[TaskCancel] 切换对话前取消任务: {canceled_count}")
|
||
if not settled:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "任务正在停止中,请稍后再试",
|
||
"message": "检测到后台任务仍在停止,请稍后再切换对话。"
|
||
}), 409
|
||
_terminate_running_sub_agents(terminal, reason="用户切换对话")
|
||
result = terminal.load_conversation(conversation_id)
|
||
|
||
if result["success"]:
|
||
normalized_id = _normalize_conv_id(conversation_id)
|
||
if _is_host_mode_request(username):
|
||
try:
|
||
vm = _get_conv_versioning_manager(workspace, normalized_id)
|
||
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||
latest_checkpoint = vm.get_latest_checkpoint() if vmeta.get("enabled") else None
|
||
latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None
|
||
expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None
|
||
mismatch = vm.detect_mismatch(latest_commit, expected_workspace_path=expected_workspace_path) if vmeta.get("enabled") else {
|
||
"has_repo": False,
|
||
"head": None,
|
||
"dirty": False,
|
||
"mismatch": False,
|
||
"workspace_matched": True,
|
||
}
|
||
result["versioning"] = {
|
||
"enabled": bool(vmeta.get("enabled")),
|
||
"mode": "overwrite",
|
||
"latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None,
|
||
"latest_commit": latest_commit,
|
||
"mismatch": bool(mismatch.get("mismatch")),
|
||
"dirty": bool(mismatch.get("dirty")),
|
||
"workspace_matched": bool(mismatch.get("workspace_matched", True)),
|
||
}
|
||
except Exception as exc:
|
||
debug_log(f"[Versioning] load status 读取失败: {exc}")
|
||
result["versioning"] = {
|
||
"enabled": False,
|
||
"mode": "overwrite",
|
||
"mismatch": False,
|
||
"error": str(exc),
|
||
}
|
||
|
||
# 广播对话切换事件
|
||
socketio.emit('conversation_changed', {
|
||
'conversation_id': conversation_id,
|
||
'title': result.get("title", "未知对话"),
|
||
'messages_count': result.get("messages_count", 0)
|
||
}, room=f"user_{username}")
|
||
|
||
# 广播系统状态更新(因为当前对话改变了)
|
||
status = terminal.get_status()
|
||
socketio.emit('status_update', status, room=f"user_{username}")
|
||
|
||
# 清理和重置相关UI状态
|
||
socketio.emit('conversation_loaded', {
|
||
'conversation_id': conversation_id,
|
||
'clear_ui': True # 提示前端清理当前UI状态
|
||
}, room=f"user_{username}")
|
||
|
||
return jsonify(result)
|
||
else:
|
||
return jsonify(result), 404 if "不存在" in result.get("message", "") else 500
|
||
|
||
except Exception as e:
|
||
print(f"[API] 加载对话错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "加载对话时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>', methods=['DELETE'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""删除特定对话"""
|
||
try:
|
||
# 检查是否是当前对话
|
||
is_current = (terminal.context_manager.current_conversation_id == conversation_id)
|
||
|
||
result = terminal.delete_conversation(conversation_id)
|
||
|
||
if result["success"]:
|
||
# 广播对话列表更新事件
|
||
socketio.emit('conversation_list_update', {
|
||
'action': 'deleted',
|
||
'conversation_id': conversation_id
|
||
}, room=f"user_{username}")
|
||
|
||
# 如果删除的是当前对话,广播对话清空事件
|
||
if is_current:
|
||
socketio.emit('conversation_changed', {
|
||
'conversation_id': None,
|
||
'title': None,
|
||
'cleared': True
|
||
}, room=f"user_{username}")
|
||
|
||
# 更新系统状态
|
||
status = terminal.get_status()
|
||
socketio.emit('status_update', status, room=f"user_{username}")
|
||
|
||
return jsonify(result)
|
||
else:
|
||
return jsonify(result), 404 if "不存在" in result.get("message", "") else 500
|
||
|
||
except Exception as e:
|
||
print(f"[API] 删除对话错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "删除对话时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/search', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def search_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""搜索对话"""
|
||
try:
|
||
query = request.args.get('q', '').strip()
|
||
limit = request.args.get('limit', 20, type=int)
|
||
|
||
if not query:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "Missing query parameter",
|
||
"message": "请提供搜索关键词"
|
||
}), 400
|
||
|
||
# 限制参数范围
|
||
limit = max(1, min(limit, 50))
|
||
|
||
result = terminal.search_conversations(query, limit)
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"results": result["results"],
|
||
"count": result["count"],
|
||
"query": query
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
print(f"[API] 搜索对话错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "搜索对话时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/messages', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""获取对话的消息历史(可选功能,用于调试或详细查看)"""
|
||
try:
|
||
# 获取完整对话数据
|
||
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
|
||
|
||
if conversation_data:
|
||
messages = conversation_data.get("messages", [])
|
||
|
||
# 可选:限制消息数量,避免返回过多数据
|
||
limit = request.args.get('limit', type=int)
|
||
if limit:
|
||
messages = messages[-limit:] # 获取最后N条消息
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"conversation_id": conversation_id,
|
||
"messages": messages,
|
||
"total_count": len(conversation_data.get("messages", []))
|
||
}
|
||
})
|
||
else:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "Conversation not found",
|
||
"message": f"对话 {conversation_id} 不存在"
|
||
}), 404
|
||
|
||
except Exception as e:
|
||
print(f"[API] 获取对话消息错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "获取对话消息时发生异常"
|
||
}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
try:
|
||
normalized_id = _normalize_conv_id(conversation_id)
|
||
host_mode = _is_host_mode_request(username)
|
||
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||
latest = manager.get_latest_checkpoint() if versioning_meta.get("enabled") and host_mode else None
|
||
mismatch = manager.detect_mismatch(
|
||
(latest or {}).get("commit"),
|
||
expected_workspace_path=(latest or {}).get("workspace_path"),
|
||
) if versioning_meta.get("enabled") and host_mode else {
|
||
"has_repo": False,
|
||
"head": None,
|
||
"dirty": False,
|
||
"mismatch": False,
|
||
"workspace_matched": True,
|
||
}
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"conversation_id": normalized_id,
|
||
"host_mode": host_mode,
|
||
"enabled": bool(versioning_meta.get("enabled")) if host_mode else False,
|
||
"mode": "overwrite",
|
||
"latest_seq": int((latest or {}).get("seq") or 0) if latest else None,
|
||
"latest_commit": (latest or {}).get("commit"),
|
||
"mismatch": bool(mismatch.get("mismatch")),
|
||
"dirty": bool(mismatch.get("dirty")),
|
||
"workspace_matched": bool(mismatch.get("workspace_matched", True)),
|
||
}
|
||
})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
try:
|
||
normalized_id = _normalize_conv_id(conversation_id)
|
||
if not _is_host_mode_request(username):
|
||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||
payload = request.get_json(silent=True) or {}
|
||
enabled = bool(payload.get("enabled"))
|
||
mode = "overwrite"
|
||
|
||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||
meta = manager.set_enabled(enabled=enabled, mode=mode)
|
||
if enabled:
|
||
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
|
||
snapshot_payload = {
|
||
"conversation_id": normalized_id,
|
||
"title": conv_data.get("title"),
|
||
"metadata": conv_data.get("metadata") or {},
|
||
"messages": conv_data.get("messages") or [],
|
||
"message_index": -1,
|
||
"run_status": "initial",
|
||
}
|
||
init_result = manager.ensure_initial_checkpoint(
|
||
workspace_path=str(workspace.project_path),
|
||
conversation_snapshot=snapshot_payload,
|
||
)
|
||
init_row = init_result.get("row") or {}
|
||
debug_log(
|
||
f"[Versioning][Init] conv={normalized_id} enabled={enabled} "
|
||
f"created={init_result.get('created')} reason={init_result.get('reason')} "
|
||
f"seq={init_row.get('seq')} commit={init_row.get('commit')}"
|
||
)
|
||
if init_row.get("commit"):
|
||
meta["last_commit"] = init_row.get("commit")
|
||
ok = _update_conversation_versioning_meta(
|
||
terminal,
|
||
normalized_id,
|
||
enabled=enabled,
|
||
mode=mode,
|
||
last_commit=meta.get("last_commit"),
|
||
last_input_seq=int(meta.get("last_input_seq") or 0),
|
||
)
|
||
if not ok:
|
||
return jsonify({"success": False, "error": "更新对话版本配置失败"}), 404
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"conversation_id": normalized_id,
|
||
"enabled": bool(meta.get("enabled")),
|
||
"mode": "overwrite",
|
||
"last_commit": meta.get("last_commit"),
|
||
"last_input_seq": int(meta.get("last_input_seq") or 0),
|
||
}
|
||
})
|
||
except VersioningError as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 400
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/checkpoints', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
try:
|
||
normalized_id = _normalize_conv_id(conversation_id)
|
||
if not _is_host_mode_request(username):
|
||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||
if not versioning_meta.get("enabled"):
|
||
return jsonify({"success": True, "data": {"enabled": False, "items": []}})
|
||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||
rows = manager.list_checkpoints()
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"enabled": True,
|
||
"mode": "overwrite",
|
||
"items": rows,
|
||
}
|
||
})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/checkpoints/<int:seq>', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
try:
|
||
normalized_id = _normalize_conv_id(conversation_id)
|
||
if not _is_host_mode_request(username):
|
||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||
row = manager.get_checkpoint_detail(seq, include_patch=True)
|
||
if not row:
|
||
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
|
||
return jsonify({"success": True, "data": row})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/restore', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
try:
|
||
normalized_id = _normalize_conv_id(conversation_id)
|
||
if not _is_host_mode_request(username):
|
||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||
payload = request.get_json(silent=True) or {}
|
||
seq = int(payload.get("seq") or 0)
|
||
if seq < 0:
|
||
return jsonify({"success": False, "error": "缺少有效 seq"}), 400
|
||
|
||
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||
if not vmeta.get("enabled"):
|
||
return jsonify({"success": False, "error": "当前对话未开启版本管理"}), 400
|
||
|
||
restore_mode = "overwrite"
|
||
|
||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||
checkpoint = manager.get_checkpoint(seq)
|
||
if not checkpoint:
|
||
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
|
||
|
||
debug_log(
|
||
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} "
|
||
f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}"
|
||
)
|
||
manager.restore_to_commit(checkpoint.get("commit"))
|
||
debug_log(
|
||
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} "
|
||
f"commit={checkpoint.get('commit')}"
|
||
)
|
||
|
||
cm = terminal.context_manager.conversation_manager
|
||
conv_data = cm.load_conversation(normalized_id) or {}
|
||
conv_meta = conv_data.get("metadata") or {}
|
||
|
||
# 优先使用 checkpoint 绑定的快照恢复对话内容,确保与提交时语义一致(发送前状态)。
|
||
snapshot = manager.get_checkpoint_snapshot(seq)
|
||
snapshot_messages = (snapshot or {}).get("messages")
|
||
if not isinstance(snapshot_messages, list):
|
||
snapshot_messages = None
|
||
snapshot_meta = (snapshot or {}).get("metadata")
|
||
if not isinstance(snapshot_meta, dict):
|
||
snapshot_meta = {}
|
||
|
||
if snapshot_messages is None:
|
||
# 兼容旧数据:若无快照文件,退回到历史索引截断。
|
||
all_messages = conv_data.get("messages") or []
|
||
msg_index = int(checkpoint.get("message_index") or 0)
|
||
snapshot_messages = all_messages[: max(0, msg_index + 1)]
|
||
debug_log(
|
||
f"[Versioning][Restore] fallback truncation conv={normalized_id} seq={seq} "
|
||
f"msg_index={msg_index} restored_messages={len(snapshot_messages)}"
|
||
)
|
||
else:
|
||
debug_log(
|
||
f"[Versioning][Restore] snapshot loaded conv={normalized_id} seq={seq} "
|
||
f"messages={len(snapshot_messages)} meta_keys={list(snapshot_meta.keys())[:8]}"
|
||
)
|
||
|
||
restore_meta = dict(conv_meta or {})
|
||
if snapshot_meta:
|
||
restore_meta.update(snapshot_meta)
|
||
|
||
restore_project_path = restore_meta.get("project_path") or str(workspace.project_path)
|
||
restore_thinking_mode = bool(restore_meta.get("thinking_mode", False))
|
||
restore_run_mode = restore_meta.get("run_mode") or ("thinking" if restore_thinking_mode else "fast")
|
||
restore_model_key = restore_meta.get("model_key")
|
||
restore_has_images = bool(restore_meta.get("has_images", False))
|
||
restore_has_videos = bool(restore_meta.get("has_videos", False))
|
||
|
||
target_conversation_id = normalized_id
|
||
ok = cm.save_conversation(
|
||
conversation_id=normalized_id,
|
||
messages=snapshot_messages,
|
||
project_path=restore_project_path,
|
||
thinking_mode=restore_thinking_mode,
|
||
run_mode=restore_run_mode,
|
||
model_key=restore_model_key,
|
||
has_images=restore_has_images,
|
||
has_videos=restore_has_videos,
|
||
)
|
||
if not ok:
|
||
return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500
|
||
prune_info = manager.prune_checkpoints_after(seq)
|
||
debug_log(
|
||
f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} "
|
||
f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} "
|
||
f"max_seq={prune_info.get('max_seq')}"
|
||
)
|
||
# 回填快照中的 metadata(含权限/模型/统计等),再覆盖 versioning 元信息。
|
||
_update_conversation_versioning_meta(
|
||
terminal,
|
||
normalized_id,
|
||
enabled=True,
|
||
mode="overwrite",
|
||
last_commit=prune_info.get("last_commit") or checkpoint.get("commit"),
|
||
last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
|
||
)
|
||
cm.update_conversation_metadata(
|
||
normalized_id,
|
||
{
|
||
**restore_meta,
|
||
"versioning": {
|
||
"enabled": True,
|
||
"mode": "overwrite",
|
||
"last_commit": prune_info.get("last_commit") or checkpoint.get("commit"),
|
||
"last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
|
||
"updated_at": datetime.now().isoformat(),
|
||
},
|
||
},
|
||
)
|
||
debug_log(
|
||
f"[Versioning][Restore] overwrite saved conv={normalized_id} seq={seq} "
|
||
f"messages={len(snapshot_messages)}"
|
||
)
|
||
|
||
# 关键修复:overwrite 场景若直接 load 同一对话,ContextManager 会先把内存中的旧历史保存回磁盘,
|
||
# 导致刚恢复的对话被覆盖。这里先清空内存历史,避免反向覆写。
|
||
try:
|
||
current_loaded_id = getattr(terminal.context_manager, "current_conversation_id", None)
|
||
if current_loaded_id == normalized_id:
|
||
terminal.context_manager.conversation_history = []
|
||
debug_log(f"[Versioning][Restore] cleared in-memory history before reload conv={normalized_id}")
|
||
except Exception as exc:
|
||
debug_log(f"[Versioning][Restore] clear in-memory history failed: {exc}")
|
||
|
||
terminal.load_conversation(target_conversation_id)
|
||
debug_log(
|
||
f"[Versioning][Restore] reload done conv={target_conversation_id} "
|
||
f"messages={len(getattr(terminal.context_manager, 'conversation_history', []) or [])}"
|
||
)
|
||
session['run_mode'] = terminal.run_mode
|
||
session['thinking_mode'] = terminal.thinking_mode
|
||
|
||
socketio.emit('conversation_list_update', {
|
||
'action': 'version_restored',
|
||
'conversation_id': target_conversation_id
|
||
}, room=f"user_{username}")
|
||
socketio.emit('conversation_changed', {
|
||
'conversation_id': target_conversation_id,
|
||
'title': (cm.load_conversation(target_conversation_id) or {}).get("title", "版本回溯对话"),
|
||
}, room=f"user_{username}")
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"restore_mode": restore_mode,
|
||
"conversation_id": target_conversation_id,
|
||
"restored_seq": int(checkpoint.get("seq") or 0),
|
||
"restored_commit": checkpoint.get("commit"),
|
||
"source_conversation_id": normalized_id,
|
||
}
|
||
})
|
||
except VersioningError as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 400
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/compress', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def compress_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""深层压缩指定对话,生成 compact 文件并切换到新对话。"""
|
||
try:
|
||
policy = resolve_admin_policy(get_current_user_record())
|
||
if policy.get("ui_blocks", {}).get("block_compress_conversation"):
|
||
return jsonify({"success": False, "error": "压缩对话已被管理员禁用"}), 403
|
||
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
||
result = asyncio.run(
|
||
run_deep_compression(
|
||
web_terminal=terminal,
|
||
workspace=workspace,
|
||
conversation_id=normalized_id,
|
||
mode="manual",
|
||
sender=None,
|
||
)
|
||
)
|
||
|
||
if not result.get("success"):
|
||
status_code = 404 if "不存在" in result.get("error", "") else (409 if result.get("in_progress") else 400)
|
||
return jsonify(result), status_code
|
||
|
||
new_conversation_id = result["compressed_conversation_id"]
|
||
|
||
load_result = terminal.load_conversation(new_conversation_id)
|
||
|
||
if load_result.get("success"):
|
||
socketio.emit('conversation_list_update', {
|
||
'action': 'compressed',
|
||
'conversation_id': new_conversation_id
|
||
}, room=f"user_{username}")
|
||
socketio.emit('conversation_changed', {
|
||
'conversation_id': new_conversation_id,
|
||
'title': load_result.get('title', '压缩后的对话'),
|
||
'messages_count': load_result.get('messages_count', 0)
|
||
}, room=f"user_{username}")
|
||
socketio.emit('conversation_loaded', {
|
||
'conversation_id': new_conversation_id,
|
||
'clear_ui': True
|
||
}, room=f"user_{username}")
|
||
|
||
response_payload = {
|
||
"success": True,
|
||
"compressed_conversation_id": new_conversation_id,
|
||
"compact_file": result.get("compact_file"),
|
||
"summary_failed": result.get("summary_failed", False),
|
||
"guide_message": result.get("guide_message"),
|
||
"load_result": load_result
|
||
}
|
||
|
||
guide_message = (result.get("guide_message") or "").strip()
|
||
if guide_message:
|
||
try:
|
||
from .tasks import task_manager
|
||
workspace_id = session.get("workspace_id") or "default"
|
||
task_rec = task_manager.create_chat_task(
|
||
username,
|
||
workspace_id,
|
||
guide_message,
|
||
images=[],
|
||
conversation_id=new_conversation_id,
|
||
videos=[],
|
||
)
|
||
response_payload["auto_task_started"] = True
|
||
response_payload["auto_task_id"] = task_rec.task_id
|
||
response_payload["auto_task_status"] = task_rec.status
|
||
except Exception as exc:
|
||
# 任务启动失败时前端会回退到手动发送 guide_message
|
||
debug_log(f"[Compression] 自动发送引导消息失败: {exc}")
|
||
response_payload["auto_task_started"] = False
|
||
response_payload["auto_task_error"] = str(exc)
|
||
else:
|
||
response_payload["auto_task_started"] = False
|
||
|
||
return jsonify(response_payload)
|
||
|
||
except Exception as e:
|
||
print(f"[API] 压缩对话错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "压缩对话时发生异常"
|
||
}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/compression_status', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversation_compression_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
try:
|
||
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
||
data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
|
||
meta = data.get("metadata", {}) or {}
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"conversation_id": normalized_id,
|
||
"compression_in_progress": bool(meta.get("compression_in_progress", False)),
|
||
"compression_mode": meta.get("compression_mode"),
|
||
"compression_stage": meta.get("compression_stage"),
|
||
"compression_error": meta.get("compression_error"),
|
||
"compression_count": int(meta.get("compression_count", 0) or 0),
|
||
"compression_job_id": meta.get("compression_job_id"),
|
||
}
|
||
})
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/compression_cancel', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def cancel_conversation_compression(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
try:
|
||
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
||
ok = terminal.context_manager.conversation_manager.update_conversation_metadata(
|
||
normalized_id,
|
||
{
|
||
"compression_in_progress": False,
|
||
"compression_mode": None,
|
||
"compression_stage": None,
|
||
"compression_resume_payload": None,
|
||
"compression_error": "用户切换对话导致压缩取消",
|
||
}
|
||
)
|
||
if not ok:
|
||
return jsonify({"success": False, "error": "对话不存在或取消失败"}), 404
|
||
return jsonify({"success": True, "conversation_id": normalized_id})
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/sub_agents', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""返回当前对话的子智能体任务列表。"""
|
||
manager = getattr(terminal, "sub_agent_manager", None)
|
||
if not manager:
|
||
return jsonify({"success": True, "data": []})
|
||
try:
|
||
try:
|
||
# 防止不同进程创建的子智能体未被当前进程感知
|
||
manager._load_state()
|
||
except Exception:
|
||
pass
|
||
conversation_id = terminal.context_manager.current_conversation_id
|
||
data = manager.get_overview(conversation_id=conversation_id)
|
||
if not data:
|
||
# 若当前对话暂无数据但存在运行中子智能体,回退为全局运行态,避免面板空白
|
||
all_overview = manager.get_overview(conversation_id=None)
|
||
if all_overview:
|
||
terminal_statuses = TERMINAL_STATUSES.union({"terminated"})
|
||
running_only = [item for item in all_overview if item.get("status") not in terminal_statuses]
|
||
if running_only:
|
||
data = running_only
|
||
debug_log(
|
||
"[SubAgent] /api/sub_agents overview "
|
||
+ json.dumps({
|
||
"conversation_id": conversation_id,
|
||
"count": len(data),
|
||
"tasks": [
|
||
{
|
||
"task_id": item.get("task_id"),
|
||
"status": item.get("status"),
|
||
"run_in_background": item.get("run_in_background"),
|
||
"conversation_id": item.get("conversation_id"),
|
||
} for item in data
|
||
],
|
||
}, ensure_ascii=False)
|
||
)
|
||
if not hasattr(terminal, "_announced_sub_agent_tasks"):
|
||
terminal._announced_sub_agent_tasks = set()
|
||
announced = terminal._announced_sub_agent_tasks
|
||
notified_from_history = set()
|
||
try:
|
||
history = getattr(terminal.context_manager, "conversation_history", []) or []
|
||
for msg in history:
|
||
meta = msg.get("metadata") or {}
|
||
task_id = meta.get("task_id")
|
||
if meta.get("sub_agent_notice") and task_id:
|
||
notified_from_history.add(task_id)
|
||
except Exception:
|
||
notified_from_history = set()
|
||
for item in data:
|
||
task_id = item.get("task_id")
|
||
raw_task = manager.tasks.get(task_id) if task_id else None
|
||
run_in_background = bool(raw_task.get("run_in_background")) if isinstance(raw_task, dict) else False
|
||
item["run_in_background"] = run_in_background
|
||
status = item.get("status")
|
||
notified_flag = bool(raw_task.get("notified")) if isinstance(raw_task, dict) else False
|
||
already_notified = (
|
||
(task_id in announced) or
|
||
(task_id in notified_from_history) or
|
||
notified_flag
|
||
)
|
||
notice_pending = (
|
||
run_in_background
|
||
and task_id
|
||
and not already_notified
|
||
and (status in TERMINAL_STATUSES or status == "terminated")
|
||
)
|
||
item["notice_pending"] = notice_pending
|
||
debug_log(
|
||
"[SubAgent] /api/sub_agents notice_pending computed "
|
||
+ json.dumps({
|
||
"conversation_id": conversation_id,
|
||
"tasks": [
|
||
{
|
||
"task_id": item.get("task_id"),
|
||
"status": item.get("status"),
|
||
"run_in_background": item.get("run_in_background"),
|
||
"notice_pending": item.get("notice_pending"),
|
||
} for item in data
|
||
],
|
||
}, ensure_ascii=False)
|
||
)
|
||
return jsonify({"success": True, "data": data})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/sub_agents/<task_id>/activity', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_sub_agent_activity(task_id: str, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""返回指定子智能体的活动记录(进度)。"""
|
||
manager = getattr(terminal, "sub_agent_manager", None)
|
||
if not manager:
|
||
return jsonify({"success": False, "error": "子智能体管理器不可用"}), 404
|
||
try:
|
||
try:
|
||
manager._load_state()
|
||
except Exception:
|
||
pass
|
||
|
||
task = manager.tasks.get(task_id)
|
||
if not task:
|
||
return jsonify({"success": False, "error": "未找到对应子智能体任务"}), 404
|
||
|
||
progress_file = task.get("progress_file")
|
||
if not progress_file:
|
||
task_root = task.get("task_root")
|
||
if task_root:
|
||
progress_file = str(Path(task_root) / "progress.jsonl")
|
||
|
||
entries: List[Dict[str, Any]] = []
|
||
limit = request.args.get("limit", "200")
|
||
try:
|
||
limit_num = max(1, min(int(limit), 1000))
|
||
except Exception:
|
||
limit_num = 200
|
||
|
||
if progress_file and Path(progress_file).exists():
|
||
try:
|
||
lines = Path(progress_file).read_text(encoding="utf-8").splitlines()
|
||
if limit_num:
|
||
lines = lines[-limit_num:]
|
||
for line in lines:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entries.append(json.loads(line))
|
||
except Exception:
|
||
continue
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": f"读取进度失败: {exc}"}), 500
|
||
|
||
payload = {
|
||
"task_id": task_id,
|
||
"status": task.get("status"),
|
||
"entries": entries,
|
||
}
|
||
return jsonify({"success": True, "data": payload})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/background_commands', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""返回当前对话的后台 run_command 列表。"""
|
||
manager = getattr(terminal, "background_command_manager", None)
|
||
if not manager:
|
||
return jsonify({"success": True, "data": []})
|
||
try:
|
||
conversation_id = terminal.context_manager.current_conversation_id
|
||
limit_raw = request.args.get("limit", "200")
|
||
try:
|
||
limit_num = max(1, min(int(limit_raw), 1000))
|
||
except Exception:
|
||
limit_num = 200
|
||
|
||
records = manager.list_records(conversation_id=conversation_id, limit=limit_num)
|
||
data = []
|
||
for rec in records:
|
||
result = rec.get("result") if isinstance(rec.get("result"), dict) else {}
|
||
data.append({
|
||
"command_id": rec.get("command_id"),
|
||
"status": rec.get("status"),
|
||
"command": rec.get("command"),
|
||
"conversation_id": rec.get("conversation_id"),
|
||
"created_at": rec.get("created_at"),
|
||
"updated_at": rec.get("updated_at"),
|
||
"finished_at": rec.get("finished_at"),
|
||
"timeout": rec.get("timeout"),
|
||
"return_code": result.get("return_code"),
|
||
"run_in_background": True,
|
||
})
|
||
return jsonify({"success": True, "data": data})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/background_commands/<command_id>', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_background_command_detail(command_id: str, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""返回指定后台 run_command 的详情(含实时输出)。"""
|
||
manager = getattr(terminal, "background_command_manager", None)
|
||
if not manager:
|
||
return jsonify({"success": False, "error": "后台命令管理器不可用"}), 404
|
||
try:
|
||
rec = manager.get_record_with_output(command_id)
|
||
if not rec:
|
||
return jsonify({"success": False, "error": "未找到对应后台命令"}), 404
|
||
|
||
current_conv_id = terminal.context_manager.current_conversation_id
|
||
rec_conv_id = rec.get("conversation_id")
|
||
if current_conv_id and rec_conv_id and rec_conv_id != current_conv_id:
|
||
return jsonify({"success": False, "error": "无权限访问该后台命令"}), 403
|
||
|
||
result = rec.get("result") if isinstance(rec.get("result"), dict) else {}
|
||
payload = {
|
||
"command_id": rec.get("command_id"),
|
||
"status": rec.get("status"),
|
||
"command": rec.get("command"),
|
||
"conversation_id": rec.get("conversation_id"),
|
||
"created_at": rec.get("created_at"),
|
||
"updated_at": rec.get("updated_at"),
|
||
"finished_at": rec.get("finished_at"),
|
||
"timeout": rec.get("timeout"),
|
||
"return_code": result.get("return_code"),
|
||
"message": result.get("message"),
|
||
"output": rec.get("output") or "",
|
||
"run_in_background": True,
|
||
}
|
||
return jsonify({"success": True, "data": payload})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/duplicate', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def duplicate_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""复制指定对话,生成新的对话副本"""
|
||
|
||
try:
|
||
result = terminal.context_manager.duplicate_conversation(conversation_id)
|
||
|
||
if not result.get("success"):
|
||
status_code = 404 if "不存在" in result.get("error", "") else 400
|
||
return jsonify(result), status_code
|
||
|
||
new_conversation_id = result["duplicate_conversation_id"]
|
||
load_result = terminal.load_conversation(new_conversation_id)
|
||
|
||
if load_result.get("success"):
|
||
socketio.emit('conversation_list_update', {
|
||
'action': 'duplicated',
|
||
'conversation_id': new_conversation_id
|
||
}, room=f"user_{username}")
|
||
socketio.emit('conversation_changed', {
|
||
'conversation_id': new_conversation_id,
|
||
'title': load_result.get('title', '复制的对话'),
|
||
'messages_count': load_result.get('messages_count', 0)
|
||
}, room=f"user_{username}")
|
||
socketio.emit('conversation_loaded', {
|
||
'conversation_id': new_conversation_id,
|
||
'clear_ui': True
|
||
}, room=f"user_{username}")
|
||
|
||
response_payload = {
|
||
"success": True,
|
||
"duplicate_conversation_id": new_conversation_id,
|
||
"load_result": load_result
|
||
}
|
||
|
||
return jsonify(response_payload)
|
||
|
||
except Exception as e:
|
||
print(f"[API] 复制对话错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "复制对话时发生异常"
|
||
}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/review_preview', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def review_conversation_preview(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""生成对话回顾预览(不落盘,只返回前若干行文本)"""
|
||
policy = resolve_admin_policy(get_current_user_record())
|
||
if policy.get("ui_blocks", {}).get("block_conversation_review"):
|
||
return jsonify({"success": False, "error": "对话引用已被管理员禁用"}), 403
|
||
try:
|
||
current_id = terminal.context_manager.current_conversation_id
|
||
if conversation_id == current_id:
|
||
return jsonify({
|
||
"success": False,
|
||
"message": "无法引用当前对话"
|
||
}), 400
|
||
|
||
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
|
||
if not conversation_data:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "Conversation not found",
|
||
"message": f"对话 {conversation_id} 不存在"
|
||
}), 404
|
||
|
||
limit = request.args.get('limit', default=20, type=int) or 20
|
||
lines = build_review_lines(conversation_data.get("messages", []), limit=limit)
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"preview": lines,
|
||
"count": len(lines)
|
||
}
|
||
})
|
||
except Exception as e:
|
||
print(f"[API] 对话回顾预览错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "生成预览时发生异常"
|
||
}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/review', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""生成完整对话回顾 Markdown 文件"""
|
||
policy = resolve_admin_policy(get_current_user_record())
|
||
if policy.get("ui_blocks", {}).get("block_conversation_review"):
|
||
return jsonify({"success": False, "error": "对话引用已被管理员禁用"}), 403
|
||
try:
|
||
current_id = terminal.context_manager.current_conversation_id
|
||
if conversation_id == current_id:
|
||
return jsonify({
|
||
"success": False,
|
||
"message": "无法引用当前对话"
|
||
}), 400
|
||
|
||
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
|
||
if not conversation_data:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "Conversation not found",
|
||
"message": f"对话 {conversation_id} 不存在"
|
||
}), 404
|
||
|
||
messages = conversation_data.get("messages", [])
|
||
lines = build_review_lines(messages)
|
||
content = "\n".join(lines) + "\n"
|
||
char_count = len(content)
|
||
|
||
uploads_dir = workspace.uploads_dir / "review"
|
||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
title = conversation_data.get("title") or "untitled"
|
||
safe_title = _sanitize_filename_component(title)
|
||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||
filename = f"review_{safe_title}_{timestamp}.md"
|
||
target = uploads_dir / filename
|
||
|
||
target.write_text(content, encoding='utf-8')
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"path": f"user_upload/review/{filename}",
|
||
"char_count": char_count
|
||
}
|
||
})
|
||
except Exception as e:
|
||
print(f"[API] 对话回顾生成错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "生成对话回顾时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/statistics', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversations_statistics(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""获取对话统计信息"""
|
||
try:
|
||
stats = terminal.context_manager.get_conversation_statistics()
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": stats
|
||
})
|
||
|
||
except Exception as e:
|
||
print(f"[API] 获取对话统计错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "获取对话统计时发生异常"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/current', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_current_conversation(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""获取当前对话信息"""
|
||
current_id = terminal.context_manager.current_conversation_id
|
||
|
||
# 如果是临时ID,返回空的对话信息
|
||
if not current_id or current_id.startswith('temp_'):
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"id": current_id,
|
||
"title": "新对话",
|
||
"messages_count": 0,
|
||
"is_temporary": True
|
||
}
|
||
})
|
||
|
||
# 如果是真实的对话ID,查找对话数据
|
||
try:
|
||
conversation_data = terminal.context_manager.conversation_manager.load_conversation(current_id)
|
||
if conversation_data:
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"id": current_id,
|
||
"title": conversation_data.get("title", "未知对话"),
|
||
"messages_count": len(conversation_data.get("messages", [])),
|
||
"is_temporary": False
|
||
}
|
||
})
|
||
else:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "对话不存在"
|
||
}), 404
|
||
|
||
except Exception as e:
|
||
print(f"[API] 获取当前对话错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e)
|
||
}), 500
|
||
|
||
@socketio.on('send_command')
|
||
def handle_command(data):
|
||
"""处理系统命令"""
|
||
command = data.get('command', '')
|
||
|
||
username, terminal, _ = get_terminal_for_sid(request.sid)
|
||
if not terminal:
|
||
emit('error', {'message': 'System not initialized'})
|
||
return
|
||
record_user_activity(username)
|
||
|
||
result = _execute_system_command(terminal, command)
|
||
emit('command_result', result)
|
||
|
||
|
||
def _execute_system_command(terminal: WebTerminal, command: str) -> Dict[str, Any]:
|
||
"""执行系统命令,供 WebSocket 与 REST API 复用。"""
|
||
command = (command or '').strip()
|
||
if command.startswith('/'):
|
||
command = command[1:]
|
||
|
||
parts = command.split(maxsplit=1)
|
||
cmd = parts[0].lower() if parts else ''
|
||
|
||
if cmd == "clear":
|
||
terminal.context_manager.conversation_history.clear()
|
||
if terminal.thinking_mode:
|
||
terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode)
|
||
return {
|
||
'command': cmd,
|
||
'success': True,
|
||
'message': '对话已清除'
|
||
}
|
||
|
||
if cmd == "status":
|
||
status = terminal.get_status()
|
||
if terminal.terminal_manager:
|
||
status['terminals'] = terminal.terminal_manager.list_terminals()
|
||
return {
|
||
'command': cmd,
|
||
'success': True,
|
||
'data': status
|
||
}
|
||
|
||
if cmd == "terminals":
|
||
if terminal.terminal_manager:
|
||
return {
|
||
'command': cmd,
|
||
'success': True,
|
||
'data': terminal.terminal_manager.list_terminals()
|
||
}
|
||
return {
|
||
'command': cmd,
|
||
'success': False,
|
||
'message': '终端系统未初始化'
|
||
}
|
||
|
||
return {
|
||
'command': cmd or command,
|
||
'success': False,
|
||
'message': f'未知命令: {cmd or command}'
|
||
}
|
||
|
||
|
||
@conversation_bp.route('/api/commands', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def execute_command_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""通过 REST API 执行系统命令(用于无 WebSocket 前端)。"""
|
||
try:
|
||
payload = request.get_json(silent=True) or {}
|
||
command = (payload.get('command') or '').strip()
|
||
if not command:
|
||
return jsonify({
|
||
"success": False,
|
||
"message": "命令不能为空"
|
||
}), 400
|
||
|
||
record_user_activity(username)
|
||
result = _execute_system_command(terminal, command)
|
||
status_code = 200 if result.get('success') else 400
|
||
return jsonify(result), status_code
|
||
except Exception as exc:
|
||
return jsonify({
|
||
"success": False,
|
||
"message": f"命令执行异常: {exc}"
|
||
}), 500
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/token-statistics', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversation_token_statistics(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""获取特定对话的token统计"""
|
||
try:
|
||
stats = terminal.context_manager.get_conversation_token_statistics(conversation_id)
|
||
|
||
if stats:
|
||
return jsonify({
|
||
"success": True,
|
||
"data": stats
|
||
})
|
||
else:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": "Conversation not found",
|
||
"message": f"对话 {conversation_id} 不存在"
|
||
}), 404
|
||
|
||
except Exception as e:
|
||
print(f"[API] 获取token统计错误: {e}")
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e),
|
||
"message": "获取token统计时发生异常"
|
||
}), 500
|
||
|
||
|
||
@conversation_bp.route('/api/conversations/<conversation_id>/tokens', methods=['GET'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_conversation_tokens(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""获取对话的当前完整上下文token数(包含所有动态内容)"""
|
||
try:
|
||
current_tokens = terminal.context_manager.get_current_context_tokens(conversation_id)
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"total_tokens": current_tokens
|
||
}
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": str(e)
|
||
}), 500
|