Luxx/luxx/services/chat.py

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"
def create_chat_service() -> ChatService:
"""Factory function to create ChatService instances."""
return ChatService()