303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""Chat service module with Agentic Loop pattern.
|
|
|
|
This module provides the core chat service that orchestrates:
|
|
- StreamContext: Manages streaming state transitions
|
|
- MessageBuilder: Constructs message lists
|
|
- AgenticLoop: Executes the agentic loop (LLM + tools iteration)
|
|
- ChatService: Core chat service facade
|
|
"""
|
|
import json
|
|
import logging
|
|
import traceback
|
|
import httpx
|
|
from typing import List, Dict, Any, AsyncGenerator
|
|
|
|
from luxx.tools.executor import ToolExecutor
|
|
from luxx.tools.core import registry
|
|
from luxx.services.llm_client import LLMClient
|
|
from luxx.services.stream_context import StreamContext
|
|
from luxx.services.agentic_loop import AgenticLoop
|
|
from luxx.config import config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============== MessageBuilder ==============
|
|
|
|
class MessageBuilder:
|
|
"""Builds message lists for LLM requests."""
|
|
|
|
def __init__(self):
|
|
self.messages = []
|
|
|
|
def add_system(self, content: str) -> 'MessageBuilder':
|
|
"""Add system message."""
|
|
self.messages.append({"role": "system", "content": content})
|
|
return self
|
|
|
|
def add_user(self, content: str, attachments: list = None) -> 'MessageBuilder':
|
|
"""Add user message in JSON format."""
|
|
msg_content = json.dumps({
|
|
"text": content,
|
|
"attachments": attachments or []
|
|
}, ensure_ascii=False)
|
|
self.messages.append({"role": "user", "content": msg_content})
|
|
return self
|
|
|
|
def add_assistant(self, content: str, tool_calls: list = None) -> 'MessageBuilder':
|
|
"""Add assistant message."""
|
|
msg = {"role": "assistant", "content": content}
|
|
if tool_calls:
|
|
msg["tool_calls"] = tool_calls
|
|
self.messages.append(msg)
|
|
return self
|
|
|
|
def add_tool_result(self, tool_call_id: str, content: str) -> 'MessageBuilder':
|
|
"""Add tool result message."""
|
|
self.messages.append({
|
|
"role": "tool",
|
|
"tool_call_id": tool_call_id,
|
|
"content": content
|
|
})
|
|
return self
|
|
|
|
def build(self) -> List[Dict]:
|
|
"""Build and return message list."""
|
|
return self.messages.copy()
|
|
|
|
@staticmethod
|
|
def extract_text(content: str) -> str:
|
|
"""Extract text from message content (supports JSON format)."""
|
|
if not content:
|
|
return ""
|
|
try:
|
|
parsed = json.loads(content)
|
|
if isinstance(parsed, dict):
|
|
return parsed.get("text", content)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
return content
|
|
|
|
|
|
# ============== Factory Function ==============
|
|
|
|
def get_llm_client(conversation=None) -> tuple:
|
|
"""Get LLM client based on conversation provider. Returns (client, max_tokens)"""
|
|
from luxx.models import LLMProvider
|
|
from luxx.database import SessionLocal
|
|
|
|
max_tokens = None
|
|
|
|
if conversation and conversation.provider_id:
|
|
db = SessionLocal()
|
|
try:
|
|
provider = db.query(LLMProvider).filter(
|
|
LLMProvider.id == conversation.provider_id
|
|
).first()
|
|
if provider:
|
|
max_tokens = provider.max_tokens
|
|
client = LLMClient(
|
|
api_key=provider.api_key,
|
|
api_url=provider.base_url,
|
|
model=provider.default_model
|
|
)
|
|
return client, max_tokens
|
|
finally:
|
|
db.close()
|
|
|
|
return LLMClient(), max_tokens
|
|
|
|
|
|
# ============== ChatService ==============
|
|
|
|
class ChatService:
|
|
"""Core chat service with Agentic Loop support."""
|
|
|
|
def __init__(self):
|
|
self.tool_executor = ToolExecutor()
|
|
self.agentic_loop = AgenticLoop(self.tool_executor)
|
|
|
|
def build_messages(self, conversation, include_system: bool = True) -> List[Dict]:
|
|
"""Build message list from conversation history."""
|
|
from luxx.database import SessionLocal
|
|
from luxx.models import Message
|
|
|
|
messages = []
|
|
|
|
if include_system and conversation.system_prompt:
|
|
messages.append({"role": "system", "content": conversation.system_prompt})
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
db_messages = db.query(Message).filter(
|
|
Message.conversation_id == conversation.id
|
|
).order_by(Message.created_at).all()
|
|
|
|
for msg in db_messages:
|
|
content = MessageBuilder.extract_text(msg.content)
|
|
messages.append({"role": msg.role, "content": content})
|
|
finally:
|
|
db.close()
|
|
|
|
return messages
|
|
|
|
def _get_tools(self, enabled_tools: list) -> list:
|
|
"""Filter tools based on enabled_tools list."""
|
|
if not enabled_tools:
|
|
return []
|
|
return [t for t in registry.list_all()
|
|
if t.get("function", {}).get("name") in enabled_tools]
|
|
|
|
async def stream_response(
|
|
self,
|
|
conversation,
|
|
user_message: str,
|
|
thinking_enabled: bool = False,
|
|
enabled_tools: list = None,
|
|
user_id: int = None,
|
|
username: str = None,
|
|
workspace: str = None,
|
|
user_permission_level: int = 1
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Streaming response with Agentic Loop."""
|
|
try:
|
|
# Build initial messages
|
|
messages = self.build_messages(conversation)
|
|
messages.append({
|
|
"role": "user",
|
|
"content": json.dumps({"text": user_message, "attachments": []})
|
|
})
|
|
|
|
# Get tools and LLM client
|
|
tools = self._get_tools(enabled_tools)
|
|
llm, provider_max_tokens = get_llm_client(conversation)
|
|
model = conversation.model or llm.default_model or "gpt-4"
|
|
max_tokens = provider_max_tokens or 8192
|
|
|
|
# Tool execution context
|
|
tool_context = {
|
|
"workspace": workspace,
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"user_permission_level": user_permission_level
|
|
}
|
|
|
|
# Stream context
|
|
ctx = StreamContext()
|
|
|
|
# Execute agentic loop
|
|
async for event in self.agentic_loop.execute(
|
|
llm=llm,
|
|
model=model,
|
|
messages=messages,
|
|
tools=tools,
|
|
temperature=conversation.temperature,
|
|
max_tokens=max_tokens,
|
|
thinking_enabled=thinking_enabled or conversation.thinking_enabled,
|
|
context=ctx,
|
|
tool_context=tool_context
|
|
):
|
|
yield event
|
|
|
|
# Save message after successful completion (only if we have content)
|
|
if ctx._last_message_id and (ctx.full_content or ctx.all_tool_calls):
|
|
self._save_message(
|
|
conversation.id,
|
|
ctx._last_message_id,
|
|
ctx.full_content,
|
|
ctx.all_tool_calls,
|
|
ctx.all_tool_results,
|
|
ctx.all_steps,
|
|
ctx._last_token_count,
|
|
ctx._last_usage
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Stream error: {e}\n{traceback.format_exc()}")
|
|
yield _sse_event("error", {"content": str(e)})
|
|
|
|
async def non_stream_response(
|
|
self,
|
|
conversation,
|
|
user_message: str,
|
|
tools_enabled: bool = True,
|
|
thinking_enabled: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""Non-streaming response for simple requests."""
|
|
try:
|
|
messages = self.build_messages(conversation)
|
|
messages.append({
|
|
"role": "user",
|
|
"content": json.dumps({"text": user_message, "attachments": []})
|
|
})
|
|
|
|
tools = [] if not tools_enabled else None
|
|
llm, max_tokens = get_llm_client(conversation)
|
|
model = conversation.model or llm.default_model or "gpt-4"
|
|
|
|
response = await llm.sync_call(
|
|
model=model,
|
|
messages=messages,
|
|
tools=tools,
|
|
temperature=conversation.temperature,
|
|
max_tokens=max_tokens or 8192,
|
|
thinking_enabled=thinking_enabled or conversation.thinking_enabled
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"content": response.content,
|
|
"tool_calls": response.tool_calls,
|
|
"usage": response.usage
|
|
}
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
error_msg = f"HTTP {e.response.status_code}: {e.response.text[:200] if e.response else 'No response body'}"
|
|
logger.error(f"Non-stream HTTP error: {error_msg}")
|
|
return {"success": False, "error": error_msg}
|
|
except httpx.TimeoutException as e:
|
|
logger.error(f"Non-stream timeout: {e}")
|
|
return {"success": False, "error": "Request timeout"}
|
|
except Exception as e:
|
|
logger.error(f"Non-stream error: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
|
return {"success": False, "error": f"{type(e).__name__}: {str(e)}"}
|
|
|
|
def _save_message(self, conversation_id: str, msg_id: str, full_content: str,
|
|
all_tool_calls: list, all_tool_results: list, all_steps: list,
|
|
token_count: int = 0, usage: dict = None):
|
|
"""Save assistant message to database."""
|
|
from luxx.database import SessionLocal
|
|
from luxx.models import Message
|
|
|
|
content_json = {"text": full_content, "steps": all_steps}
|
|
if all_tool_calls:
|
|
content_json["tool_calls"] = all_tool_calls
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
msg = Message(
|
|
id=msg_id,
|
|
conversation_id=conversation_id,
|
|
role="assistant",
|
|
content=json.dumps(content_json, ensure_ascii=False),
|
|
token_count=token_count,
|
|
usage=json.dumps(usage) if usage else None
|
|
)
|
|
db.add(msg)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _sse_event(event: str, data: dict) -> str:
|
|
"""Format a Server-Sent Event string."""
|
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
|
|
|
|
# ============== Global Singleton ==============
|
|
|
|
chat_service = ChatService()
|