fix: 修复部分bug

This commit is contained in:
ViperEkura 2026-04-12 22:13:56 +08:00
parent 9e9535c794
commit e93ec6d94d
9 changed files with 212 additions and 129 deletions

View File

@ -1,7 +1,7 @@
# 配置文件 # 配置文件
app: app:
secret_key: ${APP_SECRET_KEY} secret_key: ${APP_SECRET_KEY}
debug: false debug: true
host: 0.0.0.0 host: 0.0.0.0
port: 8000 port: 8000

View File

@ -8,12 +8,6 @@ const routes = [
component: () => import('../views/HomeView.vue'), component: () => import('../views/HomeView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/about',
name: 'About',
component: () => import('../views/AboutView.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/settings', path: '/settings',
name: 'Settings', name: 'Settings',

View File

@ -80,7 +80,7 @@ export const messagesAPI = {
// 发送消息(非流式) // 发送消息(非流式)
send: (data) => api.post('/messages/', data), send: (data) => api.post('/messages/', data),
// 发送消息(流式)- 返回 EventSource 或使用 fetch // 发送消息(流式)- 使用原生 fetch 避免 axios 拦截
sendStream: (data) => { sendStream: (data) => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
return fetch('/api/messages/stream', { return fetch('/api/messages/stream', {

View File

@ -111,8 +111,50 @@ const sendMessage = async () => {
const parsed = JSON.parse(data) const parsed = JSON.parse(data)
if (parsed.type === 'text') { if (parsed.type === 'text') {
streamContent.value += parsed.content streamContent.value += parsed.content
} else if (parsed.type === 'tool_call') {
//
const data = parsed.data
if (data && Array.isArray(data) && data.length > 0) {
//
const hasFunctionName = data.some(tc => tc.function && tc.function.name)
if (hasFunctionName) {
streamContent.value += '\n\n[调用工具] '
data.forEach(tc => {
if (tc.function && tc.function.name) {
streamContent.value += `${tc.function.name} `
}
})
}
}
} else if (parsed.type === 'tool_result') {
//
streamContent.value += '\n\n[工具结果]\n'
if (Array.isArray(parsed.data)) {
parsed.data.forEach(tr => {
if (tr.content) {
try {
const result = JSON.parse(tr.content)
if (result.success && result.data && result.data.results) {
result.data.results.forEach(r => {
streamContent.value += `${r.title}\n${r.snippet}\n\n`
})
} else {
streamContent.value += tr.content.substring(0, 500)
}
} catch {
streamContent.value += tr.content.substring(0, 500)
}
} else {
streamContent.value += '无结果'
}
})
}
} else if (parsed.type === 'error') {
streamContent.value += '\n\n[错误] ' + (parsed.error || '未知错误')
} }
} catch (e) {} } catch (e) {
console.error('Parse error:', e, data)
}
} }
} }
} }

View File

@ -63,24 +63,29 @@
<div class="card-actions"> <div class="card-actions">
<button @click="editProvider(p)">编辑</button> <button @click="editProvider(p)">编辑</button>
<button @click="testProvider(p)" :disabled="testing === p.id">测试</button> <button @click="testProvider(p)" :disabled="testing === p.id">{{ testing === p.id ? '测试中...' : '测试' }}</button>
<button @click="deleteProvider(p)" class="btn-danger">删除</button> <button @click="deleteProvider(p)" class="btn-danger">删除</button>
</div> </div>
</div> </div>
</div> </div>
<!-- 测试结果弹窗 --> <!-- 测试结果弹窗 -->
<div v-if="testResult !== null" class="modal-overlay" @click.self="testResult = null"> <div v-if="testResult !== null || testing" class="modal-overlay" @click.self="testResult = null; testing = null">
<div class="modal result-modal" :class="{ success: testResult.success, error: !testResult.success }"> <div class="modal result-modal" :class="{ success: testResult?.success === true, error: testResult?.success === false, loading: testing }">
<div class="result-icon"> <div v-if="testing" class="result-loading">
<span v-if="testResult.success">&#10003;</span> <div class="spinner-large"></div>
<span v-else>&#10007;</span> <p>测试连接中...</p>
</div> </div>
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2> <template v-else>
<div class="result-icon">
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre> <span v-if="testResult.success">&#10003;</span>
<div v-else class="result-message">{{ testResult.message }}</div> <span v-else>&#10007;</span>
<button @click="testResult = null" class="btn-primary">确定</button> </div>
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
<div v-else class="result-message">{{ testResult.message }}</div>
<button @click="testResult = null" class="btn-primary">确定</button>
</template>
</div> </div>
</div> </div>
@ -90,12 +95,12 @@
<h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2> <h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2>
<div class="form-group"><label>名称</label><input v-model="form.name" placeholder="如: 我的 DeepSeek" /></div> <div class="form-group"><label>名称</label><input v-model="form.name" placeholder="如: 我的 DeepSeek" /></div>
<div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/v1" /></div> <div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/chat/completions" /></div>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" /></div> <div class="form-group"><label>API Key</label><input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" /></div>
<div class="form-group"> <div class="form-group">
<label>模型名称</label> <label>模型名称</label>
<input v-model="form.default_model" placeholder="gpt-4 / deepseek-chat" required /> <input v-model="form.default_model" placeholder="deepseek-chat / gpt-4" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="switch-card" :class="{ active: form.is_default }"> <label class="switch-card" :class="{ active: form.is_default }">
@ -174,7 +179,6 @@ const updateUser = async () => {
savingUser.value = true savingUser.value = true
userFormError.value = '' userFormError.value = ''
try { try {
// TODO: Call API to update user info
alert('用户信息已保存') alert('用户信息已保存')
userForm.value.username = userFormEdit.value.username userForm.value.username = userFormEdit.value.username
userForm.value.email = userFormEdit.value.email userForm.value.email = userFormEdit.value.email
@ -262,20 +266,17 @@ const testProvider = async (p) => {
testResult.value = null testResult.value = null
try { try {
const res = await providersAPI.test(p.id) const res = await providersAPI.test(p.id)
const isSuccess = res.data?.success === true const isSuccess = res.success === true
testResult.value = { testResult.value = {
success: isSuccess, success: isSuccess,
message: res.data?.message || (isSuccess ? '连接成功' : '连接失败'), message: res.message || (isSuccess ? '连接成功' : '连接失败'),
status: 200, json: JSON.stringify(res, null, 2)
json: JSON.stringify(res.data || res, null, 2)
} }
} catch (e) { } catch (e) {
const errorData = e.response?.data || { error: e.message }
testResult.value = { testResult.value = {
success: false, success: false,
status: e.status || e.response?.status || '未知', message: e.message || '测试失败',
message: e.message || e.response?.data?.detail || e.response?.message || '连接失败', json: JSON.stringify(e.response?.data || { error: e.message }, null, 2)
json: JSON.stringify(errorData, null, 2)
} }
} finally { } finally {
testing.value = null testing.value = null
@ -353,13 +354,16 @@ input:checked + .slider:before { transform: translateX(22px); }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg); border-radius: 16px; padding: 2rem; width: 100%; max-width: 500px; } .modal { background: var(--bg); border-radius: 16px; padding: 2rem; width: 100%; max-width: 500px; }
.result-modal { text-align: center; } .result-modal { text-align: center; }
.result-modal.loading { min-height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.result-loading { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.spinner-large { width: 48px; height: 48px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
.result-modal .result-icon { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; } .result-modal .result-icon { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
.result-modal.success .result-icon { background: #dcfce7; color: #16a34a; } .result-modal.success .result-icon { background: #dcfce7; color: #16a34a; }
.result-modal.error .result-icon { background: var(--accent-bg); color: var(--accent); } .result-modal.error .result-icon { background: var(--accent-bg); color: var(--accent); }
.result-modal h2 { margin: 0 0 0.5rem; color: var(--text-h); } .result-modal h2 { margin: 0 0 0.5rem; color: var(--text-h); }
.result-modal .result-status { color: var(--accent); font-size: 0.9rem; margin-bottom: 0.5rem; font-weight: 500; } .result-modal .result-status { color: var(--accent); font-size: 0.9rem; margin-bottom: 0.5rem; font-weight: 500; }
.result-modal .result-message { color: var(--text); margin-bottom: 1.5rem; } .result-modal .result-message { color: var(--text); margin-bottom: 1.5rem; }
.result-modal .result-json { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin: 1rem 0; max-height: 200px; overflow: auto; font-size: 0.85rem; text-align: left; white-space: pre-wrap; word-break: break-all; color: var(--text-h); } .result-modal .result-json { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin: 1rem 0; max-height: 200px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; color: var(--text-h); }
.result-modal .btn-primary { width: 100%; } .result-modal .btn-primary { width: 100%; }
.modal h2 { margin: 0 0 1.5rem; color: var(--text-h); } .modal h2 { margin: 0 0 1.5rem; color: var(--text-h); }
.form-group { margin-bottom: 1.25rem; } .form-group { margin-bottom: 1.25rem; }

View File

@ -60,26 +60,37 @@ def create_conversation(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Create conversation""" """Create conversation"""
# Get provider info if provider_id is specified from luxx.models import LLMProvider
# Get provider info - use default provider if not specified
provider_id = data.provider_id
model = data.model model = data.model
if data.provider_id and not model:
from luxx.models import LLMProvider if not provider_id:
# Find default provider
default_provider = db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id,
LLMProvider.is_default == True
).first()
if default_provider:
provider_id = default_provider.id
if provider_id and not model:
provider = db.query(LLMProvider).filter( provider = db.query(LLMProvider).filter(
LLMProvider.id == data.provider_id, LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id LLMProvider.user_id == current_user.id
).first() ).first()
if provider: if provider:
model = provider.default_model model = provider.default_model
else:
model = "gpt-4" if not model:
elif not model:
model = "gpt-4" model = "gpt-4"
conversation = Conversation( conversation = Conversation(
id=generate_id("conv"), id=generate_id("conv"),
user_id=current_user.id, user_id=current_user.id,
project_id=data.project_id, project_id=data.project_id,
provider_id=data.provider_id, provider_id=provider_id,
title=data.title or "New Conversation", title=data.title or "New Conversation",
model=model, model=model,
system_prompt=data.system_prompt, system_prompt=data.system_prompt,

View File

@ -9,6 +9,8 @@ from luxx.routes.auth import get_current_user
from luxx.utils.helpers import success_response from luxx.utils.helpers import success_response
import httpx import httpx
import asyncio import asyncio
import traceback
router = APIRouter(prefix="/providers", tags=["LLM Providers"]) router = APIRouter(prefix="/providers", tags=["LLM Providers"])
@ -176,72 +178,53 @@ def delete_provider(
db.close() db.close()
@router.post("/{provider_id}/test", response_model=dict) @router.post("/{provider_id}/test")
def test_provider( def test_provider(
provider_id: int, provider_id: int,
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Test provider connection""" """Test provider connection"""
db = SessionLocal()
try: try:
provider = db.query(LLMProvider).filter( db = SessionLocal()
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# Test the connection
try: try:
provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if not provider:
return {"success": False, "message": "Provider not found"}
# Test the connection
async def test(): async def test():
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post( response = await client.post(
f"{provider.base_url}/chat/completions", provider.base_url,
headers={ headers={
"Authorization": f"Bearer {provider.api_key}", "Authorization": f"Bearer {provider.api_key}",
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
json={ json={
"model": provider.default_model, "model": provider.default_model,
"messages": [{"role": "user", "content": "Hi"}], "messages": [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hi"}],
"max_tokens": 10 "stream": False
} }
) )
response.raise_for_status()
return { return {
"status_code": response.status_code, "status_code": response.status_code,
"success": response.status_code == 200, "success": True,
"response_body": response.text[:500] if response.text else None "response_body": response.text[:500] if response.text else None
} }
result = asyncio.run(test()) result = asyncio.run(test())
if result["success"]: return {
return success_response(data={ "success": result.get("success", False),
"success": True, "message": result.get("message") or (f"HTTP {result.get('status_code', '?')}: {result.get('response_body') or 'Unknown error'}"),
"message": "连接成功", "data": result
"status_code": result["status_code"] }
}) finally:
else: db.close()
return success_response(data={ except Exception as e:
"success": False, return {"success": False, "message": f"Exception {str(e)}", "error_type": type(e).__name__, "traceback": traceback.format_exc()[:500]}
"message": f"HTTP {result['status_code']}",
"status_code": result["status_code"],
"response_body": result["response_body"]
})
except httpx.HTTPStatusError as e:
return success_response(data={
"success": False,
"message": f"HTTP {e.response.status_code}: {e.response.text[:200] if e.response.text else 'Unknown error'}",
"status_code": e.response.status_code,
"response_body": e.response.text[:500] if e.response.text else None
})
except Exception as e:
return success_response(data={
"success": False,
"message": f"连接失败: {str(e)}",
"error_type": type(e).__name__
})
finally:
db.close()

View File

@ -48,6 +48,9 @@ class ChatService:
include_system: bool = True include_system: bool = True
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
"""Build message list""" """Build message list"""
from luxx.database import SessionLocal
from luxx.models import Message
messages = [] messages = []
if include_system and conversation.system_prompt: if include_system and conversation.system_prompt:
@ -56,11 +59,19 @@ class ChatService:
"content": conversation.system_prompt "content": conversation.system_prompt
}) })
for msg in conversation.messages.order_by(Message.created_at).all(): db = SessionLocal()
messages.append({ try:
"role": msg.role, db_messages = db.query(Message).filter(
"content": msg.content Message.conversation_id == conversation.id
}) ).order_by(Message.created_at).all()
for msg in db_messages:
messages.append({
"role": msg.role,
"content": msg.content
})
finally:
db.close()
return messages return messages
@ -95,6 +106,8 @@ class ChatService:
while iteration < MAX_ITERATIONS: while iteration < MAX_ITERATIONS:
iteration += 1 iteration += 1
print(f"[CHAT DEBUG] ====== Starting iteration {iteration} ======")
print(f"[CHAT DEBUG] Messages count: {len(messages)}")
tool_calls_this_round = None tool_calls_this_round = None
@ -110,6 +123,7 @@ class ChatService:
if event_type == "content_delta": if event_type == "content_delta":
content = event.get("content", "") content = event.get("content", "")
if content: if content:
print(f"[CHAT DEBUG] Iteration {iteration} content: {content[:100]}...")
yield {"type": "text", "content": content} yield {"type": "text", "content": content}
elif event_type == "tool_call_delta": elif event_type == "tool_call_delta":
@ -118,8 +132,10 @@ class ChatService:
elif event_type == "done": elif event_type == "done":
tool_calls_this_round = event.get("tool_calls") tool_calls_this_round = event.get("tool_calls")
print(f"[CHAT DEBUG] Done event, tool_calls: {tool_calls_this_round}")
if tool_calls_this_round and tools_enabled: if tool_calls_this_round and tools_enabled:
print(f"[CHAT DEBUG] Executing tools: {tool_calls_this_round}")
yield {"type": "tool_call", "data": tool_calls_this_round} yield {"type": "tool_call", "data": tool_calls_this_round}
tool_results = self.tool_executor.process_tool_calls_parallel( tool_results = self.tool_executor.process_tool_calls_parallel(
@ -141,7 +157,6 @@ class ChatService:
}) })
yield {"type": "tool_result", "data": tool_results} yield {"type": "tool_result", "data": tool_results}
else: else:
break break
@ -150,6 +165,10 @@ class ChatService:
yield {"type": "done"} yield {"type": "done"}
except Exception as e:
print(f"[CHAT ERROR] Exception in stream_response: {type(e).__name__}: {str(e)}")
yield {"type": "error", "error": str(e)}
except Exception as e: except Exception as e:
yield {"type": "error", "error": str(e)} yield {"type": "error", "error": str(e)}

View File

@ -140,48 +140,78 @@ class LLMClient:
"""Stream call LLM API""" """Stream call LLM API"""
body = self._build_body(model, messages, tools, stream=True, **kwargs) body = self._build_body(model, messages, tools, stream=True, **kwargs)
async with httpx.AsyncClient(timeout=120.0) as client: # Accumulators for tool calls (need to collect from delta chunks)
async with client.stream( accumulated_tool_calls = {}
"POST",
self.api_url,
headers=self._build_headers(),
json=body
) as response:
response.raise_for_status()
async for line in response.aiter_lines(): try:
if not line.strip(): async with httpx.AsyncClient(timeout=120.0) as client:
continue async with client.stream(
"POST",
self.api_url,
headers=self._build_headers(),
json=body
) as response:
response.raise_for_status()
if line.startswith("data: "): async for line in response.aiter_lines():
data_str = line[6:] if not line.strip():
if data_str == "[DONE]":
yield {"type": "done"}
continue continue
try: if line.startswith("data: "):
chunk = json.loads(data_str) data_str = line[6:]
except json.JSONDecodeError:
continue
if "choices" not in chunk: if data_str == "[DONE]":
continue yield {"type": "done"}
continue
delta = chunk.get("choices", [{}])[0].get("delta", {}) try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
content_delta = delta.get("content", "") if "choices" not in chunk:
if content_delta: continue
yield {"type": "content_delta", "content": content_delta}
tool_calls = delta.get("tool_calls", []) delta = chunk.get("choices", [{}])[0].get("delta", {})
if tool_calls:
yield {"type": "tool_call_delta", "tool_call": tool_calls}
finish_reason = chunk.get("choices", [{}])[0].get("finish_reason") # DeepSeek reasoner: use content if available, otherwise fall back to reasoning_content
if finish_reason: content = delta.get("content")
tool_calls_finish = chunk.get("choices", [{}])[0].get("message", {}).get("tool_calls") reasoning = delta.get("reasoning_content", "")
yield {"type": "done", "tool_calls": tool_calls_finish} if content and isinstance(content, str) and content.strip():
yield {"type": "content_delta", "content": content}
elif reasoning:
yield {"type": "content_delta", "content": reasoning}
# Accumulate tool calls from delta chunks (DeepSeek sends them incrementally)
tool_calls_delta = delta.get("tool_calls", [])
for tc in tool_calls_delta:
idx = tc.get("index", 0)
if idx not in accumulated_tool_calls:
accumulated_tool_calls[idx] = {"index": idx}
if "function" in tc:
if "function" not in accumulated_tool_calls[idx]:
accumulated_tool_calls[idx]["function"] = {"name": "", "arguments": ""}
if "name" in tc["function"]:
accumulated_tool_calls[idx]["function"]["name"] += tc["function"]["name"]
if "arguments" in tc["function"]:
accumulated_tool_calls[idx]["function"]["arguments"] += tc["function"]["arguments"]
if tool_calls_delta:
yield {"type": "tool_call_delta", "tool_call": tool_calls_delta}
# Check for finish_reason to signal end of stream
choice = chunk.get("choices", [{}])[0]
finish_reason = choice.get("finish_reason")
if finish_reason:
# Build final tool_calls list from accumulated chunks
final_tool_calls = list(accumulated_tool_calls.values()) if accumulated_tool_calls else None
yield {"type": "done", "tool_calls": final_tool_calls}
except httpx.HTTPStatusError as e:
# Return error as an event instead of raising
error_text = e.response.text if e.response else str(e)
yield {"type": "error", "error": f"HTTP {e.response.status_code}: {error_text}"}
except Exception as e:
yield {"type": "error", "error": str(e)}
# Global LLM client # Global LLM client