This commit is contained in:
ViperEkura 2026-04-21 11:38:37 +08:00
parent 5025efd2ab
commit 5f44e4e4ed
9 changed files with 543 additions and 272 deletions

View File

@ -283,10 +283,22 @@ export function createRoomWS(roomId, callbacks = {}) {
} }
}, },
sendMessage: (content, userId = 'user', userName = 'User') => { sendMessage: (content, userId = 'user', userName = 'User') => {
const send = () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'send_message', content, user_id: userId, user_name: userName })) ws.send(JSON.stringify({ action: 'send_message', content, user_id: userId, user_name: userName }))
} else if (ws.readyState === WebSocket.CONNECTING) {
// Wait for connection then retry
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ action: 'send_message', content, user_id: userId, user_name: userName }))
}, { once: true })
}
}
send()
}, },
ping: () => { ping: () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'ping' })) ws.send(JSON.stringify({ action: 'ping' }))
}
}, },
close: () => ws.close() close: () => ws.close()
} }

View File

@ -33,6 +33,14 @@
</div> </div>
</div> </div>
<div class="agent-config">
<span v-if="getProviderName(agent.provider_id)" class="config-tag provider-tag">
Provider: {{ getProviderName(agent.provider_id) }}
</span>
<span v-if="agent.model" class="config-tag">模型: {{ agent.model }}</span>
<span v-if="agent.tools?.length" class="config-tag">工具: {{ agent.tools.length }}</span>
</div>
<p class="agent-prompt">{{ agent.system_prompt?.slice(0, 100) }}...</p> <p class="agent-prompt">{{ agent.system_prompt?.slice(0, 100) }}...</p>
<div class="agent-actions"> <div class="agent-actions">
@ -64,6 +72,47 @@
placeholder="定义 Agent 的行为和职责..."></textarea> placeholder="定义 Agent 的行为和职责..."></textarea>
</div> </div>
<div class="form-row">
<div class="form-group">
<label>LLM Provider</label>
<select v-model="form.provider_id">
<option :value="null">默认配置</option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.provider_type }})
</option>
</select>
</div>
<div class="form-group">
<label>模型</label>
<input v-model="form.model" type="text" :placeholder="selectedProvider?.default_model || '如: deepseek-chat'" />
</div>
</div>
<!-- Provider 详情 -->
<div v-if="selectedProvider" class="provider-details">
<div class="provider-info">
<span class="provider-type">{{ selectedProvider.provider_type }}</span>
<span v-if="selectedProvider.default_model" class="provider-model">
默认模型: {{ selectedProvider.default_model }}
</span>
<span v-if="selectedProvider.base_url" class="provider-url">
API: {{ selectedProvider.base_url }}
</span>
</div>
</div>
<div class="form-group">
<label>工具</label>
<div class="tools-grid">
<label v-for="tool in tools" :key="tool.function.name"
class="tool-checkbox" :class="{ active: form.tools.includes(tool.function.name) }">
<input type="checkbox" :checked="form.tools.includes(tool.function.name)"
@change="toggleTool(tool.function.name)" />
{{ tool.function.name }}
</label>
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>优先级</label> <label>优先级</label>
@ -103,10 +152,12 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { agentsAPI } from '@/api' import { agentsAPI, providersAPI, toolsAPI } from '@/api'
const agents = ref([]) const agents = ref([])
const providers = ref([])
const tools = ref([])
const showCreateModal = ref(false) const showCreateModal = ref(false)
const editingAgent = ref(null) const editingAgent = ref(null)
@ -114,6 +165,9 @@ const form = reactive({
name: '', name: '',
role: 'helper', role: 'helper',
system_prompt: '', system_prompt: '',
provider_id: null,
model: '',
tools: [],
priority: 5, priority: 5,
temperature: 0.7, temperature: 0.7,
max_tokens: 2048, max_tokens: 2048,
@ -121,21 +175,58 @@ const form = reactive({
mention_trigger: false mention_trigger: false
}) })
const selectedProvider = computed(() => {
if (!form.provider_id) return null
return providers.value.find(p => p.id === form.provider_id) || null
})
function getProviderName(providerId) {
if (!providerId) return null
const p = providers.value.find(p => p.id === providerId)
return p ? p.name : null
}
async function loadAgents() { async function loadAgents() {
try { try {
const res = await agentsAPI.list() const res = await agentsAPI.list()
agents.value = res.agents || [] // Support both {agents: []} and {success: true, data: {agents: []}}
agents.value = res.data?.agents || res.agents || []
} catch (e) { } catch (e) {
console.error('Failed to load agents:', e) console.error('Failed to load agents:', e)
} }
} }
async function loadProviders() {
try {
const res = await providersAPI.list()
// Support both {providers: []} and {success: true, data: {providers: []}}
providers.value = res.data?.providers || res.providers || []
} catch (e) {
console.error('Failed to load providers:', e)
providers.value = []
}
}
async function loadTools() {
try {
const res = await toolsAPI.list()
// Support both {tools: []} and {success: true, data: {tools: []}}
tools.value = res.data?.tools || res.tools || []
} catch (e) {
console.error('Failed to load tools:', e)
tools.value = []
}
}
function editAgent(agent) { function editAgent(agent) {
editingAgent.value = agent editingAgent.value = agent
Object.assign(form, { Object.assign(form, {
name: agent.name, name: agent.name,
role: agent.role, role: agent.role,
system_prompt: agent.system_prompt, system_prompt: agent.system_prompt,
provider_id: agent.provider_id || null,
model: agent.model || '',
tools: agent.tools || [],
priority: agent.priority, priority: agent.priority,
temperature: agent.temperature, temperature: agent.temperature,
max_tokens: agent.max_tokens, max_tokens: agent.max_tokens,
@ -149,6 +240,7 @@ function closeModal() {
editingAgent.value = null editingAgent.value = null
Object.assign(form, { Object.assign(form, {
name: '', role: 'helper', system_prompt: '', name: '', role: 'helper', system_prompt: '',
provider_id: null, model: '', tools: [],
priority: 5, temperature: 0.7, max_tokens: 2048, priority: 5, temperature: 0.7, max_tokens: 2048,
auto_response: true, mention_trigger: false auto_response: true, mention_trigger: false
}) })
@ -156,10 +248,23 @@ function closeModal() {
async function saveAgent() { async function saveAgent() {
try { try {
const data = { ...form }
// Handle provider_id: send clear_provider if selecting "default"
if (form.provider_id === null) {
data.clear_provider = true
delete data.provider_id
}
if (!data.model) delete data.model
if (data.tools.length === 0) delete data.tools
console.log('Saving agent with data:', data)
if (editingAgent.value) { if (editingAgent.value) {
await agentsAPI.update(editingAgent.value.id, { ...form }) await agentsAPI.update(editingAgent.value.id, data)
} else { } else {
await agentsAPI.create({ ...form }) await agentsAPI.create(data)
} }
closeModal() closeModal()
loadAgents() loadAgents()
@ -179,7 +284,20 @@ async function deleteAgent(id) {
} }
} }
onMounted(loadAgents) function toggleTool(toolName) {
const idx = form.tools.indexOf(toolName)
if (idx >= 0) {
form.tools.splice(idx, 1)
} else {
form.tools.push(toolName)
}
}
onMounted(() => {
loadAgents()
loadProviders()
loadTools()
})
</script> </script>
<style scoped> <style scoped>
@ -266,10 +384,63 @@ onMounted(loadAgents)
.agent-prompt { .agent-prompt {
font-size: 13px; font-size: 13px;
color: #666; color: #666;
margin-bottom: 16px; margin-bottom: 12px;
line-height: 1.5; line-height: 1.5;
} }
.agent-config {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.config-tag {
font-size: 11px;
padding: 2px 8px;
background: #f0f0f0;
border-radius: 10px;
color: #666;
}
.config-tag.provider-tag {
background: #667eea20;
color: #667eea;
}
.provider-details {
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.provider-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
}
.provider-type {
font-weight: 600;
color: #667eea;
background: #667eea15;
padding: 2px 8px;
border-radius: 4px;
}
.provider-model {
color: #666;
}
.provider-url {
color: #888;
font-size: 12px;
word-break: break-all;
}
.agent-actions { .agent-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -347,7 +518,8 @@ onMounted(loadAgents)
} }
.form-group input, .form-group input,
.form-group textarea { .form-group textarea,
.form-group select {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -356,6 +528,34 @@ onMounted(loadAgents)
box-sizing: border-box; box-sizing: border-box;
} }
.tools-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tool-checkbox {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 16px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.tool-checkbox input {
display: none;
}
.tool-checkbox.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.form-group textarea { .form-group textarea {
resize: vertical; resize: vertical;
font-family: inherit; font-family: inherit;

View File

@ -15,8 +15,13 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false
rewrite: (path) => path },
'/ws': {
target: 'ws://localhost:8000',
ws: true,
changeOrigin: true,
secure: false
} }
} }
}, },

View File

@ -1,11 +1,11 @@
"""Base Agent class""" """Base Agent class"""
import json import json
import uuid
import logging import logging
from typing import List, Dict, Any, Optional, AsyncGenerator from typing import List, Dict, Any, AsyncGenerator
from abc import ABC, abstractmethod from abc import ABC
from luxx.services.llm_client import LLMClient from luxx.tools.core import registry
from luxx.services.chat import chat_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -42,32 +42,6 @@ class BaseAgent(ABC):
self.auto_response = auto_response self.auto_response = auto_response
self.mention_trigger = mention_trigger self.mention_trigger = mention_trigger
self.avatar = avatar self.avatar = avatar
self.llm_client = None
def _get_llm_client(self, room_id: str = None):
"""Get LLM client, optionally using agent's provider"""
if self.llm_client:
return self.llm_client
if self.provider_id:
from luxx.core.database import SessionLocal
from luxx.models import LLMProvider
db = SessionLocal()
try:
provider = db.query(LLMProvider).filter(LLMProvider.id == self.provider_id).first()
if provider:
self.llm_client = LLMClient(
api_key=provider.api_key,
api_url=provider.base_url,
model=provider.default_model
)
return self.llm_client
finally:
db.close()
# Fallback to global config
self.llm_client = LLMClient()
return self.llm_client
async def stream_response( async def stream_response(
self, self,
@ -78,6 +52,7 @@ class BaseAgent(ABC):
) -> AsyncGenerator[Dict[str, Any], None]: ) -> AsyncGenerator[Dict[str, Any], None]:
""" """
Generate streaming response for the agent. Generate streaming response for the agent.
Reuses ChatService's core logic for consistency.
Args: Args:
user_message: The user's message user_message: The user's message
@ -88,9 +63,18 @@ class BaseAgent(ABC):
Yields: Yields:
SSE-formatted event dictionaries SSE-formatted event dictionaries
""" """
messages = [] logger.info(f"[Agent {self.name}] Starting stream_response, provider_id={self.provider_id}, model={self.model}")
# Add system prompt # Get tools if enabled
enabled_tools = []
if self.tools:
for tool_name in self.tools:
tool = registry.get(tool_name)
if tool:
enabled_tools.append(tool)
# Build messages list
messages = []
final_system_prompt = self._build_system_prompt(context) final_system_prompt = self._build_system_prompt(context)
messages.append({"role": "system", "content": final_system_prompt}) messages.append({"role": "system", "content": final_system_prompt})
@ -98,138 +82,36 @@ class BaseAgent(ABC):
if conversation_history: if conversation_history:
for msg in conversation_history[-10:]: for msg in conversation_history[-10:]:
role = "assistant" if msg["sender_type"] == "agent" else "user" role = "assistant" if msg["sender_type"] == "agent" else "user"
messages.append({ content = msg["content"]
"role": role, # Handle JSON content format
"content": msg["content"] if isinstance(content, str):
}) try:
content_obj = json.loads(content)
if isinstance(content_obj, dict):
content = content_obj.get("text", content)
except json.JSONDecodeError:
pass
messages.append({"role": role, "content": content})
# Add current user message # Add current user message
messages.append({"role": "user", "content": user_message}) messages.append({"role": "user", "content": user_message})
# Get LLM client # Delegate to ChatService's core logic
llm = self._get_llm_client() async for sse_str in chat_service.stream_response_for_agent(
# Get tools if enabled
enabled_tools = []
if self.tools:
from luxx.tools.core import registry
for tool_name in self.tools:
tool = registry.get(tool_name)
if tool:
enabled_tools.append(tool)
# Stream response
step_index = 0
full_content = ""
try:
async for sse_line in llm.stream_call(
model=self.model or llm.default_model,
messages=messages, messages=messages,
model=self.model,
tools=enabled_tools if enabled_tools else None, tools=enabled_tools if enabled_tools else None,
temperature=self.temperature, temperature=self.temperature,
max_tokens=self.max_tokens, max_tokens=self.max_tokens,
thinking_enabled=thinking_enabled thinking_enabled=thinking_enabled,
provider_id=self.provider_id,
workspace=context.get("workspace") if context else None,
user_id=context.get("user_id") if context else None,
username=context.get("username") if context else None,
user_permission_level=context.get("user_permission_level", 1) if context else 1
): ):
# Parse SSE line # Forward the SSE string with agent context appended
event_type = None yield sse_str
data_str = None
for line in sse_line.strip().split('\n'):
if line.startswith('event: '):
event_type = line[7:].strip()
elif line.startswith('data: '):
data_str = line[6:].strip()
if data_str is None:
continue
# Handle error events
if event_type == 'error':
try:
error_data = json.loads(data_str)
yield {
"event": "error",
"data": {"content": error_data.get("content", "Unknown error")}
}
except json.JSONDecodeError:
yield {
"event": "error",
"data": {"content": data_str}
}
return
# Parse the data
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
# Check for error in response
if "error" in chunk:
error_msg = chunk["error"].get("message", str(chunk["error"]))
yield {
"event": "error",
"data": {"content": f"API Error: {error_msg}"}
}
return
# Get delta
choices = chunk.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
# Handle reasoning (thinking)
reasoning = delta.get("reasoning_content", "")
if reasoning:
step_index += 1
yield {
"event": "process_step",
"data": {
"step": {
"id": f"{self.agent_id}-step-{step_index}",
"type": "thinking",
"content": reasoning
}
}
}
# Handle content
content = delta.get("content", "")
if content:
step_index += 1
full_content += content
yield {
"event": "process_step",
"data": {
"step": {
"id": f"{self.agent_id}-step-{step_index}",
"type": "text",
"content": full_content
}
}
}
# Final message
yield {
"event": "done",
"data": {
"message_id": str(uuid.uuid4()),
"agent_id": self.agent_id,
"agent_name": self.name,
"content": full_content,
"token_count": len(full_content) // 4
}
}
except Exception as e:
logger.error(f"Agent {self.name} stream error: {e}")
yield {
"event": "error",
"data": {"content": str(e)}
}
def _build_system_prompt(self, context: Dict = None) -> str: def _build_system_prompt(self, context: Dict = None) -> str:
"""Build the final system prompt with context""" """Build the final system prompt with context"""
@ -251,6 +133,7 @@ class BaseAgent(ABC):
"role": self.role, "role": self.role,
"avatar": self.avatar, "avatar": self.avatar,
"system_prompt": self.system_prompt, "system_prompt": self.system_prompt,
"provider_id": self.provider_id,
"model": self.model, "model": self.model,
"tools": self.tools, "tools": self.tools,
"priority": self.priority, "priority": self.priority,

View File

@ -37,6 +37,7 @@ class UpdateAgentRequest(BaseModel):
max_tokens: Optional[int] = None max_tokens: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
avatar: Optional[str] = None avatar: Optional[str] = None
clear_provider: bool = False
@router.get("") @router.get("")
@ -92,7 +93,8 @@ async def update_agent(agent_id: str, request: UpdateAgentRequest):
temperature=request.temperature, temperature=request.temperature,
max_tokens=request.max_tokens, max_tokens=request.max_tokens,
is_active=request.is_active, is_active=request.is_active,
avatar=request.avatar avatar=request.avatar,
clear_provider=request.clear_provider
) )
if not agent: if not agent:
raise HTTPException(status_code=404, detail="Agent not found") raise HTTPException(status_code=404, detail="Agent not found")

View File

@ -67,7 +67,8 @@ class AgentManager:
def update_agent(self, agent_id: str, name: str = None, role: str = None, system_prompt: str = None, def update_agent(self, agent_id: str, name: str = None, role: str = None, system_prompt: str = None,
provider_id: int = None, model: str = None, tools: List[str] = None, priority: int = None, provider_id: int = None, model: str = None, tools: List[str] = None, priority: int = None,
auto_response: bool = None, mention_trigger: bool = None, temperature: float = None, auto_response: bool = None, mention_trigger: bool = None, temperature: float = None,
max_tokens: int = None, is_active: bool = None, avatar: str = None) -> Optional[Dict]: max_tokens: int = None, is_active: bool = None, avatar: str = None,
clear_provider: bool = False) -> Optional[Dict]:
"""Update an agent""" """Update an agent"""
db = SessionLocal() db = SessionLocal()
try: try:
@ -81,12 +82,14 @@ class AgentManager:
agent.role = role agent.role = role
if system_prompt is not None: if system_prompt is not None:
agent.system_prompt = system_prompt agent.system_prompt = system_prompt
if provider_id is not None: if clear_provider:
agent.provider_id = None
elif provider_id is not None:
agent.provider_id = provider_id agent.provider_id = provider_id
if model is not None: if model is not None:
agent.model = model agent.model = model
if tools is not None: if tools is not None:
agent.tools = json.dumps(tools) agent.tools = json.dumps(tools) if tools else None
if priority is not None: if priority is not None:
agent.priority = priority agent.priority = priority
if auto_response is not None: if auto_response is not None:

View File

@ -4,11 +4,10 @@ import uuid
import logging import logging
from typing import List, Dict, Any, AsyncGenerator, Optional from typing import List, Dict, Any, AsyncGenerator, Optional
from luxx.models import Conversation, Message from luxx.models import Conversation
from luxx.tools.executor import ToolExecutor from luxx.tools.executor import ToolExecutor
from luxx.tools.core import registry from luxx.tools.core import registry
from luxx.services.llm_client import LLMClient from luxx.services.llm_client import LLMClient
from luxx.core.config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum iterations to prevent infinite loops # Maximum iterations to prevent infinite loops
@ -20,15 +19,23 @@ def _sse_event(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def get_llm_client(conversation: Conversation = None): def get_llm_client(conversation=None, provider_id: int = None):
"""Get LLM client, optionally using conversation's provider. Returns (client, max_tokens)""" """Get LLM client, optionally using conversation's or provider_id's settings. Returns (client, max_tokens)"""
max_tokens = None max_tokens = None
if conversation and conversation.provider_id: target_provider_id = None
# Determine provider_id
if conversation and hasattr(conversation, 'provider_id'):
target_provider_id = conversation.provider_id
if provider_id:
target_provider_id = provider_id
if target_provider_id:
from luxx.models import LLMProvider from luxx.models import LLMProvider
from luxx.core.database import SessionLocal from luxx.core.database import SessionLocal
db = SessionLocal() db = SessionLocal()
try: try:
provider = db.query(LLMProvider).filter(LLMProvider.id == conversation.provider_id).first() provider = db.query(LLMProvider).filter(LLMProvider.id == target_provider_id).first()
if provider: if provider:
max_tokens = provider.max_tokens max_tokens = provider.max_tokens
client = LLMClient( client = LLMClient(
@ -257,11 +264,10 @@ class ChatService:
user_permission_level: int = 1 user_permission_level: int = 1
) -> AsyncGenerator[Dict[str, str], None]: ) -> AsyncGenerator[Dict[str, str], None]:
""" """
Streaming response generator Streaming response generator for user conversations.
Yields raw SSE event strings for direct forwarding. Yields raw SSE event strings for direct forwarding.
""" """
try:
messages = self.build_messages(conversation) messages = self.build_messages(conversation)
messages.append({ messages.append({
@ -277,9 +283,97 @@ class ChatService:
llm, provider_max_tokens = get_llm_client(conversation) llm, provider_max_tokens = get_llm_client(conversation)
model = conversation.model or llm.default_model or "gpt-4" model = conversation.model or llm.default_model or "gpt-4"
# 直接使用 provider 的 max_tokens
max_tokens = provider_max_tokens max_tokens = provider_max_tokens
async for event in self._stream_response_core(
messages=messages,
model=model,
tools=tools,
temperature=conversation.temperature,
max_tokens=max_tokens or 8192,
thinking_enabled=thinking_enabled or conversation.thinking_enabled,
conversation_id=conversation.id,
user_id=user_id,
username=username,
workspace=workspace,
user_permission_level=user_permission_level
):
yield event
async def stream_response_for_agent(
self,
messages: List[Dict],
model: str = None,
tools: list = None,
temperature: float = 0.7,
max_tokens: int = 2048,
thinking_enabled: bool = False,
provider_id: int = None,
workspace: str = None,
user_id: int = None,
username: str = None,
user_permission_level: int = 1
) -> AsyncGenerator[Dict[str, str], None]:
"""
Streaming response generator for agents (reuses user chat logic).
Args:
messages: Pre-built message list (should include system prompt and history)
model: Model name
tools: List of tool definitions
temperature: Sampling temperature
max_tokens: Maximum tokens
thinking_enabled: Enable reasoning
provider_id: LLM provider ID
workspace: Workspace path
user_id: User ID
username: Username
user_permission_level: Permission level
Yields raw SSE event strings.
"""
llm, provider_max_tokens = get_llm_client(provider_id=provider_id)
model = model or llm.default_model or "gpt-4"
effective_max_tokens = provider_max_tokens or max_tokens
async for event in self._stream_response_core(
messages=messages,
model=model,
tools=tools or [],
temperature=temperature,
max_tokens=effective_max_tokens,
thinking_enabled=thinking_enabled,
conversation_id=None, # Agent doesn't save to conversation
provider_id=provider_id,
user_id=user_id,
username=username,
workspace=workspace,
user_permission_level=user_permission_level
):
yield event
async def _stream_response_core(
self,
messages: List[Dict],
model: str,
tools: list,
temperature: float,
max_tokens: int,
thinking_enabled: bool,
conversation_id: str = None,
provider_id: int = None,
user_id: int = None,
username: str = None,
workspace: str = None,
user_permission_level: int = 1
) -> AsyncGenerator[Dict[str, str], None]:
"""
Core streaming response logic (shared by user chat and agents).
"""
# Get LLM client
target_provider_id = provider_id if not conversation_id else None
llm, _ = get_llm_client(provider_id=target_provider_id)
# Token usage tracking # Token usage tracking
total_usage = { total_usage = {
"prompt_tokens": 0, "prompt_tokens": 0,
@ -291,7 +385,16 @@ class ChatService:
# Streaming context for state management # Streaming context for state management
ctx = StreamContext() ctx = StreamContext()
for iteration in range(MAX_ITERATIONS): # Build tool context
tool_context = {
"workspace": workspace,
"user_id": user_id,
"username": username,
"user_permission_level": user_permission_level
}
try:
for _ in range(MAX_ITERATIONS):
# Reset streaming context for this iteration # Reset streaming context for this iteration
ctx.reset_iteration() ctx.reset_iteration()
@ -299,9 +402,9 @@ class ChatService:
model=model, model=model,
messages=messages, messages=messages,
tools=tools, tools=tools,
temperature=conversation.temperature, temperature=temperature,
max_tokens=max_tokens or 8192, max_tokens=max_tokens or 8192,
thinking_enabled=thinking_enabled or conversation.thinking_enabled thinking_enabled=thinking_enabled
): ):
# Parse SSE line # Parse SSE line
# Format: "event: xxx\ndata: {...}\n\n" # Format: "event: xxx\ndata: {...}\n\n"
@ -449,8 +552,10 @@ class ChatService:
actual_token_count = total_usage.get("completion_tokens", 0) or len(ctx.full_content) // 4 actual_token_count = total_usage.get("completion_tokens", 0) or len(ctx.full_content) // 4
logger.info(f"[TOKEN] total_usage: {total_usage}, actual_token_count: {actual_token_count}") logger.info(f"[TOKEN] total_usage: {total_usage}, actual_token_count: {actual_token_count}")
# Only save to DB if conversation_id is provided
if conversation_id:
self._save_message( self._save_message(
conversation.id, conversation_id,
msg_id, msg_id,
ctx.full_content, ctx.full_content,
ctx.all_tool_calls, ctx.all_tool_calls,
@ -468,10 +573,10 @@ class ChatService:
return return
# Max iterations exceeded - save message before error # Max iterations exceeded - save message before error
if ctx.full_content or ctx.all_tool_calls: if conversation_id and (ctx.full_content or ctx.all_tool_calls):
msg_id = str(uuid.uuid4()) msg_id = str(uuid.uuid4())
self._save_message( self._save_message(
conversation.id, conversation_id,
msg_id, msg_id,
ctx.full_content, ctx.full_content,
ctx.all_tool_calls, ctx.all_tool_calls,

View File

@ -55,6 +55,12 @@ class LLMClient:
def _build_headers(self) -> Dict[str, str]: def _build_headers(self) -> Dict[str, str]:
"""Build request headers""" """Build request headers"""
if not self.api_key:
raise ValueError(
"LLM API key is not configured. "
"Please set DEEPSEEK_API_KEY environment variable or configure a provider with API key."
)
return { return {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}" "Authorization": f"Bearer {self.api_key}"
@ -131,6 +137,7 @@ class LLMClient:
headers=self._build_headers(), headers=self._build_headers(),
json=body json=body
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()

View File

@ -80,19 +80,73 @@ class ResponseAggregator:
if not agent_streams: if not agent_streams:
return return
import asyncio
def parse_sse(event_str: str) -> Dict[str, Any]:
"""Parse SSE string to dict."""
lines = event_str.strip().split('\n')
result = {"event": None, "data": {}}
for line in lines:
if line.startswith('event: '):
result["event"] = line[7:].strip()
elif line.startswith('data: '):
try:
result["data"] = json.loads(line[6:].strip())
except json.JSONDecodeError:
result["data"] = {"content": line[6:].strip()}
return result
async def collect_agent_stream(agent_id: str, stream): async def collect_agent_stream(agent_id: str, stream):
"""Collect all events from a single agent stream."""
try: try:
async for event in stream: async for event in stream:
event["agent_id"] = agent_id # Event is SSE string from BaseAgent
yield event parsed = parse_sse(event)
parsed["agent_id"] = agent_id
yield parsed
except Exception as e: except Exception as e:
logger.error(f"Agent {agent_id} stream error: {e}") logger.error(f"Agent {agent_id} stream error: {e}")
yield {"event": "error", "agent_id": agent_id, "data": {"content": str(e)}} yield {"event": "error", "agent_id": agent_id, "data": {"content": str(e)}}
tasks = [collect_agent_stream(agent_id, stream) for agent_id, stream in agent_streams.items()] # Use a queue-based approach for merging
async for event in asyncio.merge(*tasks): queue = asyncio.Queue()
async def producer(agent_id: str, stream):
try:
async for event in stream:
# Parse SSE string to dict if needed
if isinstance(event, str):
parsed = parse_sse(event)
parsed["agent_id"] = agent_id
await queue.put((agent_id, parsed))
else:
# Already a dict, just add agent_id
if isinstance(event, dict):
event["agent_id"] = agent_id
await queue.put((agent_id, event))
except Exception as e:
logger.error(f"Agent {agent_id} stream error: {e}")
await queue.put((agent_id, {"event": "error", "agent_id": agent_id, "data": {"content": str(e)}}))
finally:
await queue.put((agent_id, None)) # Signal done
# Start all producers
producers = [
asyncio.create_task(producer(agent_id, stream))
for agent_id, stream in agent_streams.items()
]
active = len(producers)
while active > 0:
agent_id, event = await queue.get()
if event is None:
active -= 1
else:
yield event yield event
# Wait for all producers to complete
await asyncio.gather(*producers, return_exceptions=True)
def aggregate_final(self, responses: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]: def aggregate_final(self, responses: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Aggregate final responses from agents.""" """Aggregate final responses from agents."""
results = [] results = []