Files

2341 lines
79 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Chat API 端点"""
import asyncio
import json
import re
import uuid
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db, get_db_session_factory
from backend.modules.agent.context import ContextBuilder
from backend.modules.agent.loop import AgentLoop
from backend.modules.agent.memory import MemoryStore
from backend.modules.agent.skills import SkillsLoader
from backend.modules.agent.team_commands import (
build_team_command_overview,
build_team_goal_usage,
resolve_explicit_team_command,
)
from backend.modules.config.loader import config_loader
from backend.modules.config.schema import ModelConfig, PersonaConfig
from backend.modules.external_agents.conversation import (
build_history_prompt,
resolve_effective_session_mode,
)
from backend.modules.external_agents.routing import (
build_explicit_external_agent_system_message,
extract_explicit_external_agent_request,
)
from backend.modules.providers import create_provider
from backend.modules.providers.runtime import (
build_provider_unavailable_message,
get_provider_runtime_state,
)
from backend.modules.session import (
ConversationContextService,
resolve_session_runtime_config,
schedule_context_maintenance,
)
from backend.modules.session.manager import SessionManager
from backend.modules.session.message_context import (
MAX_CHAT_ATTACHMENTS,
build_attachment_item,
build_attachment_items_from_workspace,
build_message_context,
build_workspace_attachment_destination,
extract_attachment_items_from_message_context,
extract_reasoning_content_from_message_context,
normalize_assistant_persistence_payload,
resolve_workspace_attachments,
)
from backend.modules.tools.registry import ToolRegistry
from backend.utils.datetime_utils import to_utc_iso
from backend.utils.paths import WORKSPACE_DIR
router = APIRouter(prefix="/api/chat", tags=["chat"])
MAX_CHAT_ATTACHMENT_SIZE = 25 * 1024 * 1024
DEFAULT_TOOL_PREVIEW_LIMIT = 1200
MAX_TOOL_PREVIEW_LIMIT = 8000
SPECIAL_HISTORY_TOOL_NAMES = {"spawn", "workflow_run"}
def _normalize_api_mode(value: Any) -> str:
return "chat_completions"
def _extract_reasoning_content_from_message_context(raw: Optional[str]) -> Optional[str]:
return extract_reasoning_content_from_message_context(raw)
def _extract_attachment_items_from_message_context(raw: Optional[str]) -> List[Dict[str, Any]]:
return extract_attachment_items_from_message_context(raw)
def _resolve_active_workspace() -> Path:
config = config_loader.config
return Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
def _normalize_session_model_overrides(raw_model: Dict[str, Any]) -> Dict[str, Any]:
"""校验并标准化会话级模型覆盖配置。"""
config = config_loader.config
candidate = dict(raw_model)
api_key = candidate.pop("api_key", None)
api_base = candidate.pop("api_base", None)
if "api_mode" in candidate:
candidate["api_mode"] = _normalize_api_mode(candidate.get("api_mode"))
effective_model_data = config.model.model_dump()
for key, value in candidate.items():
if value is not None:
effective_model_data[key] = value
validated_model = ModelConfig(**effective_model_data)
validated_dump = validated_model.model_dump()
normalized_overrides: Dict[str, Any] = {}
for key in candidate:
normalized_overrides[key] = validated_dump[key]
if "api_key" in raw_model:
normalized_overrides["api_key"] = "" if api_key is None else str(api_key)
if "api_base" in raw_model:
normalized_overrides["api_base"] = "" if api_base is None else str(api_base).strip()
return normalized_overrides
def _normalize_session_persona_overrides(raw_persona: Dict[str, Any]) -> Dict[str, Any]:
"""校验并标准化会话级 persona 覆盖配置。"""
config = config_loader.config
candidate = dict(raw_persona)
if "heartbeat" in candidate and candidate["heartbeat"] is not None:
heartbeat_override = candidate["heartbeat"]
if not isinstance(heartbeat_override, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="persona_config.heartbeat must be an object",
)
base_heartbeat = config.persona.heartbeat.model_dump()
candidate["heartbeat"] = {
**base_heartbeat,
**{key: value for key, value in heartbeat_override.items() if value is not None},
}
effective_persona_data = config.persona.model_dump()
for key, value in candidate.items():
if value is not None:
effective_persona_data[key] = value
validated_persona = PersonaConfig(**effective_persona_data)
validated_dump = validated_persona.model_dump()
return {
key: validated_dump[key]
for key in candidate
}
async def _require_session(db: AsyncSession, session_id: str):
from sqlalchemy import select
from backend.models.session import Session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return session
def _validate_message_or_attachments(message: str, attachments: Optional[List[str]]) -> str:
normalized_message = str(message or "")
if normalized_message.strip() or attachments:
return normalized_message
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Message or attachments are required"
)
def _resolve_attachment_inputs(attachments: Optional[List[str]], workspace: Path) -> List[tuple[str, Path]]:
try:
return resolve_workspace_attachments(attachments, workspace=workspace, max_attachments=MAX_CHAT_ATTACHMENTS)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
async def _notify_webui_session_updated(session_id: str) -> None:
"""通知 WebUI 指定会话和会话列表需要刷新。"""
try:
from backend.ws.connection import (
ServerMessage,
connection_manager,
send_dict_to_session,
)
await send_dict_to_session(
session_id,
{
"type": "history_updated",
"sessionId": session_id,
},
)
await connection_manager.broadcast(ServerMessage(type="sessions_updated"))
except Exception as exc:
logger.debug(f"Failed to notify WebUI for session {session_id}: {exc}")
# ============================================================================
# Request/Response Models
# ============================================================================
class SendMessageRequest(BaseModel):
"""发送消息请求"""
session_id: str = Field(..., description="会话 ID")
message: str = Field(default="", description="用户消息内容")
attachments: Optional[List[str]] = Field(None, description="附件路径列表(可选)")
class AttachmentItemResponse(BaseModel):
"""附件响应"""
path: str
name: str
size: int
content_type: Optional[str] = None
kind: str
class UpdateSessionSummaryRequest(BaseModel):
"""更新会话总结请求"""
summary: str = Field(..., min_length=10, max_length=200, description="会话总结10-200字符")
class SummarizeSessionResponse(BaseModel):
"""总结会话响应"""
success: bool = Field(..., description="是否成功")
summary: str = Field(..., description="生成的总结")
message: Optional[str] = Field(None, description="消息")
class SendMessageResponse(BaseModel):
"""发送消息响应"""
message_id: str = Field(..., description="消息 ID")
streaming: bool = Field(True, description="是否为流式响应")
class SessionResponse(BaseModel):
"""会话响应"""
id: str
name: str
created_at: str
updated_at: str
summary: Optional[str] = None
summary_updated_at: Optional[str] = None
class ToolCallResponse(BaseModel):
"""工具调用响应"""
id: str
name: str
arguments: Dict[str, Any] = Field(default_factory=dict)
result: Optional[str] = None
error: Optional[str] = None
status: str = "success"
duration: Optional[int] = None
spawn_task: Optional[Dict[str, Any]] = None # 子代理任务详情(仅 spawn 工具调用)
detail_available: bool = False
detail_loaded: bool = True
result_truncated: bool = False
error_truncated: bool = False
class MessageResponse(BaseModel):
"""消息响应"""
id: int
session_id: str
role: str
content: str
reasoning_content: Optional[str] = None
attachment_items: List[AttachmentItemResponse] = Field(default_factory=list)
created_at: str
tool_call_count: int = 0
special_tool_call_names: List[str] = Field(default_factory=list)
tool_calls: List[ToolCallResponse] = Field(default_factory=list, description="工具调用记录")
class MessageToolCallPageResponse(BaseModel):
"""单条消息的工具调用分页响应"""
message_id: int
total: int
offset: int
limit: int
has_more: bool
items: List[ToolCallResponse] = Field(default_factory=list)
def _clamp_tool_preview_limit(value: int) -> int:
return max(120, min(int(value), MAX_TOOL_PREVIEW_LIMIT))
def _parse_tool_arguments(raw: Optional[str]) -> Dict[str, Any]:
if not raw:
return {}
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
return {"value": parsed}
def _truncate_text(value: Optional[str], limit: int) -> tuple[Optional[str], bool]:
if value is None:
return None, False
normalized = str(value)
if len(normalized) <= limit:
return normalized, False
trimmed = normalized[:limit].rstrip()
return f"{trimmed}\n...", True
def _extract_spawn_task_id(result_text: Optional[str]) -> Optional[str]:
if not result_text:
return None
try:
parsed = json.loads(result_text)
if isinstance(parsed, dict) and parsed.get("task_id"):
return str(parsed["task_id"]).strip()
except (TypeError, json.JSONDecodeError):
pass
match = re.search(r"\(ID: ([a-f0-9\-]+)\)", result_text)
if match:
return match.group(1)
return None
async def _load_spawn_task_detail(
*,
tool_call_result: Optional[str],
subagent_mgr,
db: AsyncSession,
) -> Optional[Dict[str, Any]]:
task_id = _extract_spawn_task_id(tool_call_result)
if not task_id:
return None
if subagent_mgr:
task = subagent_mgr.get_task(task_id)
if task:
return task.to_dict()
from sqlalchemy import select
from backend.models.task import Task as TaskModel
result = await db.execute(
select(TaskModel).where(TaskModel.id == task_id)
)
db_task = result.scalar_one_or_none()
if not db_task:
return None
tool_call_records = []
if db_task.tool_call_records:
try:
tool_call_records = json.loads(db_task.tool_call_records)
except json.JSONDecodeError as exc:
logger.warning(f"Failed to parse tool_call_records: {exc}")
return {
"task_id": db_task.id,
"label": db_task.label,
"message": db_task.message,
"session_id": db_task.session_id,
"status": db_task.status,
"progress": db_task.progress,
"result": db_task.result,
"error": db_task.error,
"created_at": to_utc_iso(db_task.created_at),
"started_at": to_utc_iso(db_task.started_at),
"completed_at": to_utc_iso(db_task.completed_at),
"tool_call_records": tool_call_records,
}
async def _build_tool_call_response(
*,
tc,
db: AsyncSession,
subagent_mgr,
tool_mode: Literal["full", "summary", "none"] = "full",
preview_limit: int = DEFAULT_TOOL_PREVIEW_LIMIT,
) -> ToolCallResponse:
arguments = _parse_tool_arguments(tc.arguments)
status_value = "error" if tc.error else "success"
use_full_payload = tool_mode == "full" or tc.tool_name in SPECIAL_HISTORY_TOOL_NAMES
result_text = tc.result
error_text = tc.error
result_truncated = False
error_truncated = False
detail_available = False
detail_loaded = True
if tool_mode == "summary" and not use_full_payload:
result_text, result_truncated = _truncate_text(tc.result, preview_limit)
error_text, error_truncated = _truncate_text(tc.error, preview_limit)
detail_available = result_truncated or error_truncated
detail_loaded = not detail_available
spawn_task = None
if tc.tool_name == "spawn":
spawn_task = await _load_spawn_task_detail(
tool_call_result=tc.result,
subagent_mgr=subagent_mgr,
db=db,
)
return ToolCallResponse(
id=tc.id,
name=tc.tool_name,
arguments=arguments,
result=result_text,
error=error_text,
status=status_value,
duration=tc.duration_ms,
spawn_task=spawn_task,
detail_available=detail_available,
detail_loaded=detail_loaded,
result_truncated=result_truncated,
error_truncated=error_truncated,
)
# ============================================================================
# Dependency Injection
# ============================================================================
# ============================================================================
# Global SubagentManager (shared across all requests)
# ============================================================================
_global_subagent_manager = None
def set_global_subagent_manager(manager):
"""设置全局 SubagentManager 实例"""
global _global_subagent_manager
_global_subagent_manager = manager
logger.info("Global SubagentManager set")
def get_global_subagent_manager():
"""获取全局 SubagentManager 实例"""
return _global_subagent_manager
async def get_agent_loop(
request: Request,
db: AsyncSession = Depends(get_db),
session_id: Optional[str] = None
) -> AgentLoop:
"""获取 AgentLoop 实例(依赖注入)
Args:
request: FastAPI 请求对象
db: 数据库会话
session_id: 可选的会话 ID如果提供则使用会话级配置
"""
global _global_subagent_manager
try:
config = config_loader.config
session = None
if session_id:
from sqlalchemy import select
from backend.models.session import Session
result = await db.execute(
select(Session).where(Session.id == session_id)
)
session = result.scalar_one_or_none()
runtime_config = resolve_session_runtime_config(config, session)
if session_id and runtime_config.use_custom_config:
logger.info(f"会话使用自定义运行时配置:{session_id}")
from pathlib import Path
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
workspace.mkdir(parents=True, exist_ok=True)
# 初始化 LLM Provider使用有效配置
provider_name = runtime_config.provider_name
runtime_state = get_provider_runtime_state(
config,
provider_name,
api_key_override=runtime_config.api_key,
api_base_override=runtime_config.api_base,
)
if not runtime_state.selectable:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=build_provider_unavailable_message(provider_name, runtime_state.reason)
)
provider = create_provider(
api_key=runtime_state.api_key or None,
api_keys=runtime_state.api_keys or None,
api_base=runtime_state.api_base,
default_model=runtime_config.model_name,
api_mode=runtime_config.api_mode,
timeout=120.0,
max_retries=3,
provider_id=provider_name,
)
# 初始化记忆系统和技能加载器
memory_dir = workspace / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
memory = MemoryStore(memory_dir)
# 优先使用全局 skills 实例(性能优化)
shared = getattr(request.app.state, 'shared', None)
if hasattr(request.app.state, 'skills'):
skills = request.app.state.skills
logger.debug("Using global skills instance from app.state")
elif shared and shared.get('skills') is not None:
skills = shared['skills']
logger.debug("Using global skills instance from app.state")
else:
# 回退:创建临时实例
skills_dir = workspace / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
skills = SkillsLoader(skills_dir)
logger.warning("Creating temporary SkillsLoader instance in chat endpoint")
context_builder = ContextBuilder(
workspace=workspace,
memory=memory,
skills=skills,
persona_config=runtime_config.persona_config,
)
# 创建或获取全局 SubagentManager
from backend.modules.agent.subagent import SubagentManager
if _global_subagent_manager is None:
_global_subagent_manager = SubagentManager(
provider=provider,
workspace=workspace,
model=config.model.model,
temperature=config.model.temperature,
max_tokens=config.model.max_tokens,
db_session_factory=get_db_session_factory(),
config_loader=config_loader,
skills=skills,
)
logger.info("Created global SubagentManager")
from backend.modules.tools.setup import register_all_tools
tools = register_all_tools(
workspace=workspace,
command_timeout=config.security.command_timeout,
max_output_length=config.security.max_output_length,
allow_dangerous=not config.security.dangerous_commands_blocked,
restrict_to_workspace=config.security.restrict_to_workspace,
custom_deny_patterns=config.security.custom_deny_patterns,
custom_allow_patterns=config.security.custom_allow_patterns if config.security.command_whitelist_enabled else None,
audit_log_enabled=config.security.audit_log_enabled,
subagent_manager=_global_subagent_manager,
skills_loader=skills,
session_id=None,
memory_store=memory,
)
agent_loop = AgentLoop(
provider=provider,
workspace=workspace,
tools=tools,
context_builder=context_builder,
subagent_manager=_global_subagent_manager,
model=runtime_config.model_name,
max_iterations=runtime_config.max_iterations,
max_retries=3,
retry_delay=1.0,
temperature=runtime_config.temperature,
max_tokens=runtime_config.max_tokens,
thinking_enabled=runtime_config.thinking_enabled,
)
return agent_loop
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to initialize AgentLoop: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to initialize agent: {str(e)}"
)
def _resolve_explicit_external_tool_request(
agent_loop: AgentLoop,
message: str,
) -> Optional[tuple[str, str]]:
"""Resolve natural-language routing like '用 claude 帮我写个爬虫'."""
parsed = extract_explicit_external_agent_request(message)
if not parsed or not agent_loop.tools:
return None
requested_profile, task = parsed
external_tool = agent_loop.tools.get_tool("external_coding_agent")
registry = getattr(external_tool, "registry", None)
if registry is None:
return None
try:
canonical_name = registry.resolve_profile_name(requested_profile)
registry.resolve_profile(canonical_name)
except Exception:
return None
return canonical_name, task
def _resolve_explicit_team_workflow_request(
agent_loop: AgentLoop,
message: str,
) -> Optional[tuple[str, str]]:
"""Resolve explicit deterministic workflow command: /team <team_name> <goal>."""
context_builder = getattr(agent_loop, "context_builder", None)
return resolve_explicit_team_command(
context_builder,
message,
log_scope="web chat explicit team command",
)
def _prepare_external_task(profile, task: str, history: list[dict]) -> str:
"""根据 profile 会话模式构造实际任务。"""
session_mode = resolve_effective_session_mode(profile)
if session_mode in {"stateless", "native"}:
return task
return build_history_prompt(
task=task,
history_messages=history,
history_message_count=profile.history_message_count,
)
def _inject_explicit_external_request_context(
agent_loop: AgentLoop,
context: list[dict],
explicit_external_request: Optional[tuple[str, str]],
) -> list[dict]:
"""Force WebUI explicit external-agent requests through the normal tool-call loop."""
if not explicit_external_request:
return list(context)
external_tool = agent_loop.tools.get_tool("external_coding_agent") if agent_loop.tools else None
registry = getattr(external_tool, "registry", None)
if registry is None:
return list(context)
profile_name, task = explicit_external_request
profile = registry.resolve_profile(profile_name)
prepared_task = _prepare_external_task(profile, task, context)
system_message = build_explicit_external_agent_system_message(
profile.name,
prepared_task,
)
if not system_message:
return list(context)
augmented_context = list(context)
if augmented_context and augmented_context[0].get("role") == "system":
existing_content = str(augmented_context[0].get("content", "") or "")
separator = "\n\n" if existing_content else ""
augmented_context[0] = {
**augmented_context[0],
"content": f"{existing_content}{separator}{system_message}",
}
else:
augmented_context.insert(0, {"role": "system", "content": system_message})
return augmented_context
# ============================================================================
# Chat Endpoints
# ============================================================================
@router.post("/sessions/{session_id}/attachments", response_model=AttachmentItemResponse)
async def upload_chat_attachment(
session_id: str,
req: Request,
db: AsyncSession = Depends(get_db),
) -> AttachmentItemResponse:
"""将前端拖拽/粘贴/选择的文件写入当前工作空间附件目录。"""
await _require_session(db, session_id)
raw_filename = req.headers.get("x-file-name", "").strip()
try:
decoded_filename = Path(unquote(raw_filename)).name if raw_filename else ""
except Exception:
decoded_filename = raw_filename or ""
content_type = req.headers.get("content-type", "").strip() or None
declared_size = req.headers.get("x-file-size", "").strip()
if declared_size:
try:
declared_size_int = int(declared_size)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid X-File-Size header",
) from exc
if declared_size_int < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid X-File-Size header",
)
if declared_size_int > MAX_CHAT_ATTACHMENT_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Attachment exceeds max size {MAX_CHAT_ATTACHMENT_SIZE} bytes",
)
workspace = _resolve_active_workspace()
relative_path, destination = build_workspace_attachment_destination(
session_id=session_id,
filename=decoded_filename or "attachment",
content_type=content_type,
workspace=workspace,
)
written = 0
try:
with destination.open("wb") as output:
async for chunk in req.stream():
if not chunk:
continue
written += len(chunk)
if written > MAX_CHAT_ATTACHMENT_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Attachment exceeds max size {MAX_CHAT_ATTACHMENT_SIZE} bytes",
)
output.write(chunk)
except HTTPException:
destination.unlink(missing_ok=True)
raise
except Exception as exc:
destination.unlink(missing_ok=True)
logger.exception(f"Failed to upload attachment for session {session_id}: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload attachment",
) from exc
if written <= 0:
destination.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Empty attachment body",
)
item = build_attachment_item(
relative_path=relative_path,
absolute_path=destination,
content_type=content_type,
)
return AttachmentItemResponse(**item)
@router.get("/sessions/{session_id}/attachments/{attachment_path:path}")
async def download_chat_attachment(
session_id: str,
attachment_path: str,
db: AsyncSession = Depends(get_db),
):
"""获取/下载会话附件文件。"""
from fastapi.responses import FileResponse
import mimetypes
await _require_session(db, session_id)
workspace = _resolve_active_workspace()
safe_path = attachment_path.strip().replace("\\", "/")
resolved_file = (workspace / safe_path).resolve()
try:
relative = resolved_file.relative_to(workspace)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Attachment path is outside workspace",
) from exc
session_prefix = str(Path("uploads") / "chat" / session_id)
if not str(relative).startswith(session_prefix):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Attachment does not belong to this session",
)
if not resolved_file.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Attachment not found",
)
mime_type, _ = mimetypes.guess_type(resolved_file.name)
return FileResponse(
resolved_file,
media_type=mime_type or "application/octet-stream",
filename=resolved_file.name,
)
@router.get("/sessions/{session_id}/workspace/{file_path:path}")
async def download_workspace_file(
session_id: str,
file_path: str,
db: AsyncSession = Depends(get_db),
):
"""获取/下载工作区文件(供 send_media 等工具的参数预览使用)。"""
from fastapi.responses import FileResponse
import mimetypes
await _require_session(db, session_id)
workspace = _resolve_active_workspace()
safe_path = file_path.strip().replace("\\", "/")
resolved_file = (workspace / safe_path).resolve()
try:
resolved_file.relative_to(workspace)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="File path is outside workspace",
) from exc
if not resolved_file.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
if resolved_file.stat().st_size > 50 * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large to serve",
)
mime_type, _ = mimetypes.guess_type(resolved_file.name)
return FileResponse(
resolved_file,
media_type=mime_type or "application/octet-stream",
filename=resolved_file.name,
)
@router.get("/workspace/{file_path:path}")
async def download_workspace_file_public(
file_path: str,
):
"""获取/下载工作区文件(无需 session 校验,供 send_media 工具预览使用)。"""
from fastapi.responses import FileResponse
import mimetypes
workspace = _resolve_active_workspace()
safe_path = file_path.strip().replace("\\", "/")
resolved_file = (workspace / safe_path).resolve()
try:
resolved_file.relative_to(workspace)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="File path is outside workspace",
) from exc
if not resolved_file.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
if resolved_file.stat().st_size > 50 * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large to serve",
)
mime_type, _ = mimetypes.guess_type(resolved_file.name)
return FileResponse(
resolved_file,
media_type=mime_type or "application/octet-stream",
filename=resolved_file.name,
)
@router.post("/send", response_model=SendMessageResponse)
async def send_message(
request: SendMessageRequest,
req: Request,
db: AsyncSession = Depends(get_db),
) -> StreamingResponse:
"""
发送消息到 Agent 并获取 SSE 流式响应。
客户端监听 'message' 事件接收响应片段。
"""
try:
normalized_message = _validate_message_or_attachments(request.message, request.attachments)
session = await _require_session(db, request.session_id)
runtime_config = resolve_session_runtime_config(config_loader.config, session)
workspace = _resolve_active_workspace()
resolved_attachments = _resolve_attachment_inputs(request.attachments, workspace)
attachment_items = build_attachment_items_from_workspace(resolved_attachments)
attachment_paths = [relative_path for relative_path, _ in resolved_attachments]
# 保存用户消息到数据库
session_manager = SessionManager(db)
user_message = await session_manager.add_message(
session_id=request.session_id,
role="user",
content=normalized_message,
message_context=build_message_context(attachment_items=attachment_items),
)
if user_message is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save user message"
)
logger.info(
f"Processing message for session {request.session_id}: "
f"{normalized_message[:50]}..."
)
# 创建会话专属的 AgentLoop支持会话级配置
agent_loop = await get_agent_loop(req, db, session_id=request.session_id)
explicit_external_request = _resolve_explicit_external_tool_request(
agent_loop,
normalized_message,
)
explicit_team_request = _resolve_explicit_team_workflow_request(
agent_loop,
normalized_message,
)
context_service = ConversationContextService(db)
context_payload = await context_service.build_model_context(
session_id=request.session_id,
max_history_messages=runtime_config.persona_config.max_history_messages,
enable_short_context_summary=runtime_config.persona_config.enable_short_context_summary,
)
context = context_payload.history
# 排除刚添加的用户消息(因为 process_message 会添加)
if context and context[-1].get("role") == "user":
context = context[:-1]
from backend.modules.agent.task_manager import CancellationToken
cancel_token = CancellationToken()
# 创建流式响应生成器
async def event_stream() -> AsyncIterator[str]:
"""SSE 事件流生成器"""
assistant_content = ""
assistant_reasoning = ""
try:
# 发送开始事件
yield f"event: start\ndata: {json.dumps({'message_id': str(user_message.id)})}\n\n"
if explicit_external_request:
profile_name, _task = explicit_external_request
logger.info(
"Routing web chat explicit external-agent request through agent loop: "
f"profile={profile_name}, session={request.session_id}"
)
context_for_processing = _inject_explicit_external_request_context(
agent_loop,
context,
explicit_external_request,
)
else:
context_for_processing = context
if explicit_team_request is not None:
team_name, goal = explicit_team_request
agent_loop.tools.set_session_id(request.session_id)
agent_loop.tools.set_channel("web-chat")
agent_loop.tools.set_cancel_token(cancel_token)
if normalized_message.strip() == "/team":
assistant_content = build_team_command_overview(
getattr(agent_loop, "context_builder", None),
log_scope="web chat explicit team command",
)
elif not team_name:
assistant_content = build_team_command_overview(
getattr(agent_loop, "context_builder", None),
log_scope="web chat explicit team command",
)
elif not goal:
assistant_content = build_team_goal_usage(team_name)
else:
logger.info(
"Routing web chat directly to workflow_run: "
f"team={team_name}, session={request.session_id}"
)
assistant_content = await agent_loop.tools.execute(
tool_name="workflow_run",
arguments={
"team_name": team_name,
"goal": goal,
},
)
yield f"event: message\ndata: {json.dumps({'content': assistant_content})}\n\n"
await asyncio.sleep(0)
else:
prefer_direct_workflow_result = False
team_finder = getattr(agent_loop.context_builder, "_find_mentioned_team", None)
if callable(team_finder):
try:
prefer_direct_workflow_result = bool(team_finder(normalized_message))
except Exception as exc:
logger.warning(f"Failed to detect mentioned team for SSE chat: {exc}")
pending_reasoning_chunks: List[str] = []
async def reasoning_event_handler(reasoning_chunk: str) -> None:
nonlocal assistant_reasoning
assistant_reasoning += reasoning_chunk or ""
if reasoning_chunk:
pending_reasoning_chunks.append(reasoning_chunk)
async for chunk in agent_loop.process_message(
message=normalized_message,
session_id=request.session_id,
context=context_for_processing,
session_summary=context_payload.session_summary,
media=attachment_paths,
channel="web-chat",
cancel_token=cancel_token,
reasoning_event_handler=reasoning_event_handler,
prefer_direct_workflow_result=prefer_direct_workflow_result,
):
while pending_reasoning_chunks:
reasoning_chunk = pending_reasoning_chunks.pop(0)
yield (
"event: reasoning\n"
f"data: {json.dumps({'content': reasoning_chunk}, ensure_ascii=False)}\n\n"
)
assistant_content += chunk
# 发送内容块
yield f"event: message\ndata: {json.dumps({'content': chunk})}\n\n"
# 确保立即发送
await asyncio.sleep(0)
while pending_reasoning_chunks:
reasoning_chunk = pending_reasoning_chunks.pop(0)
yield (
"event: reasoning\n"
f"data: {json.dumps({'content': reasoning_chunk}, ensure_ascii=False)}\n\n"
)
# 保存助手响应到数据库
persisted_content, normalized_reasoning, used_reasoning_fallback = (
normalize_assistant_persistence_payload(
assistant_content,
assistant_reasoning,
)
)
assistant_message_context = (
build_message_context(reasoning_content=normalized_reasoning)
)
if persisted_content:
if used_reasoning_fallback:
yield (
"event: message\n"
f"data: {json.dumps({'content': persisted_content}, ensure_ascii=False)}\n\n"
)
await asyncio.sleep(0)
assistant_message = await session_manager.add_message(
session_id=request.session_id,
role="assistant",
content=persisted_content,
message_context=assistant_message_context,
)
try:
from backend.modules.tools.conversation_history import get_conversation_history
conversation_history = get_conversation_history()
await conversation_history.backfill_message_id(
session_id=request.session_id,
message_id=assistant_message.id,
)
except Exception as backfill_err:
logger.warning(f"Failed to backfill tool conversations for session {request.session_id}: {backfill_err}")
schedule_context_maintenance(
db_session_factory=get_db_session_factory(),
session_id=request.session_id,
max_history_messages=runtime_config.persona_config.max_history_messages,
enable_short_context_summary=runtime_config.persona_config.enable_short_context_summary,
provider=agent_loop.provider,
model=runtime_config.model_name,
memory_store=getattr(agent_loop.context_builder, "memory", None),
auto_summary_source="web-chat",
)
# 发送完成事件
yield f"event: done\ndata: {json.dumps({'message_id': str(assistant_message.id)})}\n\n"
else:
# 没有内容,发送空完成事件
yield f"event: done\ndata: {json.dumps({'message_id': None})}\n\n"
except asyncio.CancelledError:
cancel_token.cancel()
raise
except Exception as e:
logger.exception(f"Error in event stream: {e}")
# 发送错误事件
error_data = {
"error": str(e),
"type": type(e).__name__,
}
yield f"event: error\ndata: {json.dumps(error_data)}\n\n"
finally:
if agent_loop.tools:
agent_loop.tools.set_cancel_token(None)
# 返回 SSE 流式响应
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 禁用 Nginx 缓冲
},
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to send message: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to process message: {str(e)}"
)
@router.get("/sessions", response_model=List[SessionResponse])
async def list_sessions(
limit: Optional[int] = None,
offset: int = 0,
db: AsyncSession = Depends(get_db),
) -> List[SessionResponse]:
"""
获取所有会话列表
Args:
limit: 返回数量限制(可选)
offset: 偏移量
db: 数据库会话
Returns:
List[SessionResponse]: 会话列表
"""
try:
session_manager = SessionManager(db)
sessions = await session_manager.list_sessions(limit=limit, offset=offset)
return [
SessionResponse(
id=session.id,
name=session.name,
created_at=to_utc_iso(session.created_at),
updated_at=to_utc_iso(session.updated_at),
summary=session.summary,
summary_updated_at=to_utc_iso(session.summary_updated_at),
)
for session in sessions
]
except Exception as e:
logger.exception(f"Failed to list sessions: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list sessions: {str(e)}"
)
@router.post("/sessions", response_model=SessionResponse)
async def create_session(
name: str,
db: AsyncSession = Depends(get_db),
) -> SessionResponse:
"""
创建新会话
Args:
name: 会话名称
db: 数据库会话
Returns:
SessionResponse: 创建的会话
"""
try:
session_manager = SessionManager(db)
session = await session_manager.create_session(name=name)
return SessionResponse(
id=session.id,
name=session.name,
created_at=to_utc_iso(session.created_at),
updated_at=to_utc_iso(session.updated_at),
)
except Exception as e:
logger.exception(f"Failed to create session: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create session: {str(e)}"
)
@router.delete("/sessions/{session_id}")
async def delete_session(
session_id: str,
db: AsyncSession = Depends(get_db),
) -> Dict[str, bool]:
"""
删除会话
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
dict: 删除结果
Raises:
HTTPException: 会话不存在
"""
try:
session_manager = SessionManager(db)
success = await session_manager.delete_session(session_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return {"success": True}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to delete session: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete session: {str(e)}"
)
class UpdateSessionRequest(BaseModel):
"""更新会话请求"""
name: str
@router.put("/sessions/{session_id}", response_model=SessionResponse)
async def update_session(
session_id: str,
request: UpdateSessionRequest,
db: AsyncSession = Depends(get_db),
) -> SessionResponse:
"""
更新会话信息
Args:
session_id: 会话 ID
request: 更新请求(包含新的会话名称)
db: 数据库会话
Returns:
SessionResponse: 更新后的会话
Raises:
HTTPException: 会话不存在
"""
try:
session_manager = SessionManager(db)
session = await session_manager.update_session(session_id, name=request.name)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return SessionResponse(
id=session.id,
name=session.name,
created_at=to_utc_iso(session.created_at),
updated_at=to_utc_iso(session.updated_at),
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to update session: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update session: {str(e)}"
)
@router.get("/sessions/{session_id}/messages", response_model=List[MessageResponse])
async def get_session_messages(
session_id: str,
limit: Optional[int] = None,
offset: int = 0,
tool_mode: Literal["full", "summary", "none"] = "full",
tool_preview_limit: int = Query(
DEFAULT_TOOL_PREVIEW_LIMIT,
ge=120,
le=MAX_TOOL_PREVIEW_LIMIT,
),
db: AsyncSession = Depends(get_db),
) -> List[MessageResponse]:
"""
获取会话的消息列表(包含工具调用记录)
Args:
session_id: 会话 ID
limit: 返回数量限制(可选)
offset: 偏移量
db: 数据库会话
Returns:
List[MessageResponse]: 消息列表(包含关联的工具调用)
Raises:
HTTPException: 会话不存在
"""
try:
from sqlalchemy import func, select
from backend.models.tool_conversation import ToolConversation
# 获取全局 subagent_manager
subagent_mgr = get_global_subagent_manager()
session_manager = SessionManager(db)
# 验证会话是否存在
session = await session_manager.get_session(session_id)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
# 获取消息
messages = await session_manager.get_messages(
session_id=session_id,
limit=limit,
offset=offset,
)
tool_call_counts: Dict[int, int] = {}
special_tool_names_by_message: Dict[int, List[str]] = {}
tool_calls_by_message: Dict[int, List[ToolConversation]] = {}
if messages:
valid_message_ids = {msg.id for msg in messages}
count_query = (
select(
ToolConversation.message_id,
func.count(ToolConversation.id),
)
.where(ToolConversation.session_id == session_id)
.where(ToolConversation.message_id.in_(valid_message_ids))
.group_by(ToolConversation.message_id)
)
count_result = await db.execute(count_query)
tool_call_counts = {
int(message_id): int(total)
for message_id, total in count_result.all()
if message_id is not None
}
special_tool_query = (
select(ToolConversation.message_id, ToolConversation.tool_name)
.where(ToolConversation.session_id == session_id)
.where(ToolConversation.message_id.in_(valid_message_ids))
.where(ToolConversation.tool_name.in_(SPECIAL_HISTORY_TOOL_NAMES))
.order_by(ToolConversation.timestamp.asc())
)
special_tool_result = await db.execute(special_tool_query)
for message_id, tool_name in special_tool_result.all():
if message_id is None or not tool_name:
continue
bucket = special_tool_names_by_message.setdefault(int(message_id), [])
if tool_name not in bucket:
bucket.append(str(tool_name))
if tool_mode != "none":
preview_limit = _clamp_tool_preview_limit(tool_preview_limit)
tool_calls_query = (
select(ToolConversation)
.where(ToolConversation.session_id == session_id)
.where(ToolConversation.message_id.in_(valid_message_ids))
.order_by(ToolConversation.timestamp.asc())
)
tool_calls_result = await db.execute(tool_calls_query)
all_tool_calls = tool_calls_result.scalars().all()
for tc in all_tool_calls:
if tc.message_id is not None:
tool_calls_by_message.setdefault(tc.message_id, []).append(tc)
else:
preview_limit = DEFAULT_TOOL_PREVIEW_LIMIT
else:
preview_limit = DEFAULT_TOOL_PREVIEW_LIMIT
# 构建响应,包含工具调用
response_messages = []
for msg in messages:
# 获取该消息关联的工具调用
msg_tool_calls = tool_calls_by_message.get(msg.id, [])
# 转换工具调用为响应格式
tool_call_responses = []
for tc in msg_tool_calls:
tool_call_responses.append(
await _build_tool_call_response(
tc=tc,
db=db,
subagent_mgr=subagent_mgr,
tool_mode=tool_mode,
preview_limit=preview_limit,
)
)
response_messages.append(
MessageResponse(
id=msg.id,
session_id=msg.session_id,
role=msg.role,
content=msg.content,
reasoning_content=_extract_reasoning_content_from_message_context(
getattr(msg, "message_context", None)
),
attachment_items=[
AttachmentItemResponse(**item)
for item in _extract_attachment_items_from_message_context(
getattr(msg, "message_context", None)
)
],
created_at=to_utc_iso(msg.created_at),
tool_call_count=tool_call_counts.get(msg.id, 0),
special_tool_call_names=special_tool_names_by_message.get(msg.id, []),
tool_calls=tool_call_responses,
)
)
return response_messages
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to get messages: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get messages: {str(e)}"
)
@router.get(
"/sessions/{session_id}/messages/{message_id}/tool-calls",
response_model=MessageToolCallPageResponse,
)
async def get_message_tool_calls(
session_id: str,
message_id: int,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
tool_mode: Literal["full", "summary"] = "summary",
tool_preview_limit: int = Query(
DEFAULT_TOOL_PREVIEW_LIMIT,
ge=120,
le=MAX_TOOL_PREVIEW_LIMIT,
),
db: AsyncSession = Depends(get_db),
) -> MessageToolCallPageResponse:
"""分页获取单条消息关联的工具调用。"""
try:
from sqlalchemy import func, select
from backend.models.message import Message
from backend.models.tool_conversation import ToolConversation
subagent_mgr = get_global_subagent_manager()
session_manager = SessionManager(db)
session = await session_manager.get_session(session_id)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found",
)
message_result = await db.execute(
select(Message)
.where(Message.id == message_id)
.where(Message.session_id == session_id)
)
message = message_result.scalar_one_or_none()
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Message '{message_id}' not found in session '{session_id}'",
)
total_result = await db.execute(
select(func.count(ToolConversation.id))
.where(ToolConversation.session_id == session_id)
.where(ToolConversation.message_id == message_id)
)
total = int(total_result.scalar_one() or 0)
preview_limit = _clamp_tool_preview_limit(tool_preview_limit)
tool_calls_result = await db.execute(
select(ToolConversation)
.where(ToolConversation.session_id == session_id)
.where(ToolConversation.message_id == message_id)
.order_by(ToolConversation.timestamp.asc())
.limit(limit)
.offset(offset)
)
tool_calls = tool_calls_result.scalars().all()
items: List[ToolCallResponse] = []
for tc in tool_calls:
items.append(
await _build_tool_call_response(
tc=tc,
db=db,
subagent_mgr=subagent_mgr,
tool_mode=tool_mode,
preview_limit=preview_limit,
)
)
return MessageToolCallPageResponse(
message_id=message_id,
total=total,
offset=offset,
limit=limit,
has_more=offset + len(items) < total,
items=items,
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to get message tool calls: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get message tool calls: {str(e)}"
)
@router.get("/tool-calls/{tool_call_id}", response_model=ToolCallResponse)
async def get_tool_call_detail(
tool_call_id: str,
db: AsyncSession = Depends(get_db),
) -> ToolCallResponse:
"""按需获取单条工具调用的完整详情。"""
try:
from sqlalchemy import select
from backend.models.tool_conversation import ToolConversation
subagent_mgr = get_global_subagent_manager()
result = await db.execute(
select(ToolConversation).where(ToolConversation.id == tool_call_id)
)
tool_call = result.scalar_one_or_none()
if tool_call is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool call '{tool_call_id}' not found",
)
return await _build_tool_call_response(
tc=tool_call,
db=db,
subagent_mgr=subagent_mgr,
tool_mode="full",
preview_limit=DEFAULT_TOOL_PREVIEW_LIMIT,
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to get tool call detail: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get tool call detail: {str(e)}"
)
@router.delete("/sessions/{session_id}/messages/{message_id}")
async def delete_single_message(
session_id: str,
message_id: int,
db: AsyncSession = Depends(get_db),
) -> dict:
"""
删除单条消息及其关联的工具调用记录
Args:
session_id: 会话 ID
message_id: 消息 ID
db: 数据库会话
Returns:
dict: 操作结果(包含删除的工具调用记录数量)
Raises:
HTTPException: 会话或消息不存在
"""
try:
session_manager = SessionManager(db)
# 验证会话是否存在
session = await session_manager.get_session(session_id)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
# 验证消息是否存在且属于该会话
from sqlalchemy import select
from backend.models.message import Message
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.session_id == session_id
)
)
message = result.scalar_one_or_none()
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Message '{message_id}' not found in session '{session_id}'"
)
# 使用 SessionManager 的 delete_message 方法(会自动清理工具调用记录)
success = await session_manager.delete_message(message_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete message {message_id}"
)
logger.info(f"Deleted message {message_id} from session {session_id}")
return {
"success": True,
"message": f"Message {message_id} and its tool conversations deleted"
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to delete message: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete message: {str(e)}"
)
@router.delete("/sessions/{session_id}/messages")
async def clear_session_messages(
session_id: str,
db: AsyncSession = Depends(get_db),
) -> dict:
"""
清空会话的所有消息
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
dict: 操作结果
Raises:
HTTPException: 会话不存在或清空失败
"""
try:
session_manager = SessionManager(db)
# 验证会话是否存在
session = await session_manager.get_session(session_id)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
# 清空消息
await session_manager.clear_messages(session_id)
logger.info(f"Cleared all messages for session: {session_id}")
return {
"success": True,
"message": f"All messages cleared for session {session_id}"
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to clear messages: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear messages: {str(e)}"
)
@router.get("/sessions/{session_id}/export")
async def export_session_context(
session_id: str,
db: AsyncSession = Depends(get_db),
agent_loop: AgentLoop = Depends(get_agent_loop),
) -> dict:
"""
导出会话的完整上下文(包括系统提示词、工具定义、所有消息和工具调用历史)
导出内容:
1. 系统提示词(发送给 LLM 的完整上下文)
2. 工具定义(所有可用工具的描述和参数)
3. 用户消息和 AI 回复(数据库中保存的内容)
4. 工具执行历史(从工具历史记录中获取)
Args:
session_id: 会话 ID
db: 数据库会话
agent_loop: Agent 循环实例
Returns:
dict: 包含系统提示词、工具定义、消息和工具历史的完整上下文
Raises:
HTTPException: 会话不存在
"""
try:
session_manager = SessionManager(db)
# 验证会话是否存在
session = await session_manager.get_session(session_id)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
# 获取所有消息
messages = await session_manager.get_messages(session_id=session_id)
# 构建系统提示词
system_prompt = agent_loop.context_builder.build_system_prompt()
# 获取工具定义
tool_definitions = []
if agent_loop.tools:
tool_definitions = agent_loop.tools.get_definitions()
# 构建消息历史
message_history = []
for msg in messages:
message_history.append({
"id": msg.id,
"role": msg.role,
"content": msg.content,
"created_at": to_utc_iso(msg.created_at),
})
# 获取工具执行历史(从工具注册表)
tool_history = []
if agent_loop.tools and hasattr(agent_loop.tools, 'history'):
# 过滤出属于当前会话的工具调用
for record in agent_loop.tools.history:
# 工具历史记录可能没有 session_id所以我们导出所有记录
# 并在前端/导出文件中标注这是全局工具历史
tool_history.append({
"tool": record.get("tool"),
"arguments": record.get("arguments"),
"result": record.get("result"),
"error": record.get("error"),
"success": record.get("success"),
"duration": record.get("duration"),
"timestamp": to_utc_iso(record.get("timestamp")),
})
return {
"session_id": session_id,
"session_name": session.name,
"system_prompt": system_prompt,
"tool_definitions": tool_definitions,
"messages": message_history,
"tool_history": tool_history,
"exported_at": to_utc_iso(datetime.now(timezone.utc)),
"note": "此导出包含完整的系统提示词和工具定义。工具调用历史为全局记录,可能包含其他会话的工具调用。"
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to export session context: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to export session context: {str(e)}"
)
# ============================================================================
# Session Summary Endpoints
# ============================================================================
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(
session_id: str,
db: AsyncSession = Depends(get_db),
) -> SessionResponse:
"""
获取会话详情(包含总结)
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
SessionResponse: 会话详情
Raises:
HTTPException: 会话不存在
"""
try:
context_service = ConversationContextService(db)
session = await context_service.get_session(session_id)
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
return SessionResponse(
id=session.id,
name=session.name,
created_at=to_utc_iso(session.created_at),
updated_at=to_utc_iso(session.updated_at),
summary=session.summary,
summary_updated_at=to_utc_iso(session.summary_updated_at),
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to get session: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get session: {str(e)}"
)
@router.put("/sessions/{session_id}/summary")
async def update_session_summary(
session_id: str,
request: UpdateSessionSummaryRequest,
db: AsyncSession = Depends(get_db),
) -> dict:
"""
更新会话总结
Args:
session_id: 会话 ID
request: 更新总结请求
db: 数据库会话
Returns:
dict: 更新结果
Raises:
HTTPException: 会话不存在
"""
try:
context_service = ConversationContextService(db)
session = await context_service.update_session_summary(
session_id,
request.summary,
)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
logger.info(f"Updated summary for session {session_id}")
await _notify_webui_session_updated(session_id)
return {
"success": True,
"session_id": session_id,
"summary": session.summary,
"updated_at": to_utc_iso(session.summary_updated_at),
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to update session summary: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update session summary: {str(e)}"
)
@router.delete("/sessions/{session_id}/summary")
async def delete_session_summary(
session_id: str,
db: AsyncSession = Depends(get_db),
) -> dict:
"""
删除会话总结
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
dict: 删除结果
Raises:
HTTPException: 会话不存在
"""
try:
context_service = ConversationContextService(db)
session = await context_service.clear_session_summary(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
logger.info(f"Deleted summary for session {session_id}")
await _notify_webui_session_updated(session_id)
return {"success": True, "session_id": session_id}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to delete session summary: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete session summary: {str(e)}"
)
@router.post("/sessions/{session_id}/summarize", response_model=SummarizeSessionResponse)
async def summarize_session_to_memory(
session_id: str,
db: AsyncSession = Depends(get_db),
agent_loop: AgentLoop = Depends(get_agent_loop),
) -> SummarizeSessionResponse:
"""总结会话内容并保存到长期记忆
使用 LLM 直接总结对话为一行记忆条目,写入 MEMORY.md。
Args:
session_id: 会话 ID
db: 数据库会话
agent_loop: Agent 循环实例
Returns:
SummarizeSessionResponse: 总结结果
"""
try:
from sqlalchemy import select
from backend.models.session import Session
from pathlib import Path
from backend.modules.agent.analyzer import MessageAnalyzer
from backend.modules.agent.prompts import CONVERSATION_TO_MEMORY_PROMPT
logger.info(f"Starting memory summarization for session {session_id}")
# 1. 验证会话
result = await db.execute(
select(Session).where(Session.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
# 2. 获取消息
session_manager = SessionManager(db)
messages = await session_manager.get_messages(session_id=session_id)
if not messages or len(messages) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session has no messages to summarize"
)
# 3. 格式化消息
analyzer = MessageAnalyzer()
message_dicts = [
{"role": msg.role, "content": msg.content}
for msg in messages
]
formatted = analyzer.format_messages_for_summary(message_dicts, max_chars=4000)
# 4. 用 LLM 生成总结
prompt = CONVERSATION_TO_MEMORY_PROMPT.format(messages=formatted)
summary_content = ""
async for chunk in agent_loop.provider.chat_stream(
messages=[{"role": "user", "content": prompt}],
model=agent_loop.model,
temperature=0.3,
):
if chunk.is_content and chunk.content:
summary_content += chunk.content
summary = summary_content.strip()
# 5. 如果 LLM 认为无需记录,直接返回
if "无需记录" in summary:
return SummarizeSessionResponse(
success=True,
summary=summary,
message="对话无需记录到长期记忆",
)
# 6. 写入记忆
config = config_loader.config
workspace = Path(config.workspace.path) if config.workspace.path else WORKSPACE_DIR
memory_dir = workspace / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
memory = MemoryStore(memory_dir)
# 确定来源 - 使用渠道标识而非会话名称
source = "web-chat"
line_num = memory.append_entry(source=source, content=summary)
logger.info(f"Memory saved at line {line_num}: {summary[:80]}...")
return SummarizeSessionResponse(
success=True,
summary=summary,
message=f"已保存到记忆第 {line_num} 行(共 {memory.get_line_count()} 条)",
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to summarize session: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to summarize session: {str(e)}"
)
# ============================================================================
# Session Configuration Endpoints
# ============================================================================
class SessionConfigRequest(BaseModel):
"""会话配置请求"""
model: Optional[Dict[str, Any]] = Field(None, alias='model_config')
persona: Optional[Dict[str, Any]] = Field(None, alias='persona_config')
class Config:
populate_by_name = True
class SessionConfigResponse(BaseModel):
"""会话配置响应"""
session_id: str
use_custom_config: bool
model: Dict[str, Any] = Field(..., alias='model_config')
persona: Dict[str, Any] = Field(..., alias='persona_config')
global_defaults: Dict[str, Any]
class Config:
populate_by_name = True
@router.get("/sessions/{session_id}/config", response_model=SessionConfigResponse)
async def get_session_config(
session_id: str,
db: AsyncSession = Depends(get_db),
) -> SessionConfigResponse:
"""获取会话配置
返回会话的有效配置(如果有自定义配置则合并,否则返回全局默认)
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
SessionConfigResponse: 会话配置
Raises:
HTTPException: 会话不存在
"""
try:
from sqlalchemy import select
from backend.models.session import Session
# 获取会话
result = await db.execute(
select(Session).where(Session.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
config = config_loader.config
runtime_config = resolve_session_runtime_config(config, session)
global_defaults = resolve_session_runtime_config(config, None)
return SessionConfigResponse(
session_id=session_id,
use_custom_config=session.use_custom_config,
model_config=runtime_config.model_response,
persona_config=runtime_config.persona_response,
global_defaults={
"model": global_defaults.model_response,
"persona": global_defaults.persona_response,
}
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to get session config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get session config: {str(e)}"
)
@router.put("/sessions/{session_id}/config")
async def update_session_config(
session_id: str,
request: SessionConfigRequest,
db: AsyncSession = Depends(get_db),
) -> dict:
"""更新会话配置
Args:
session_id: 会话 ID
request: 配置更新请求
db: 数据库会话
Returns:
dict: 更新结果
Raises:
HTTPException: 会话不存在或配置无效
"""
try:
from sqlalchemy import select
from backend.models.session import Session
# 获取会话
result = await db.execute(
select(Session).where(Session.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
# 更新配置
if request.model is not None:
normalized_model = _normalize_session_model_overrides(dict(request.model))
session.session_model_config = json.dumps(
normalized_model,
ensure_ascii=False,
)
if request.persona is not None:
normalized_persona = _normalize_session_persona_overrides(dict(request.persona))
session.session_persona_config = json.dumps(
normalized_persona,
ensure_ascii=False,
)
session.use_custom_config = True
session.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(session)
logger.info(f"Updated config for session {session_id}")
await _notify_webui_session_updated(session_id)
return {
"success": True,
"session_id": session_id,
"message": "Session configuration updated"
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to update session config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update session config: {str(e)}"
)
@router.delete("/sessions/{session_id}/config")
async def reset_session_config(
session_id: str,
db: AsyncSession = Depends(get_db),
) -> dict:
"""重置会话配置为全局默认
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
dict: 重置结果
Raises:
HTTPException: 会话不存在
"""
try:
from sqlalchemy import select
from backend.models.session import Session
result = await db.execute(
select(Session).where(Session.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session '{session_id}' not found"
)
session.session_model_config = None
session.session_persona_config = None
session.use_custom_config = False
session.updated_at = datetime.now(timezone.utc)
await db.commit()
logger.info(f"Reset config for session {session_id}")
await _notify_webui_session_updated(session_id)
return {
"success": True,
"session_id": session_id,
"message": "Session configuration reset to global defaults"
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to reset session config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset session config: {str(e)}"
)