fix: 修复api 适配问题
This commit is contained in:
parent
e070dca10e
commit
c332380080
|
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="page-btn" :disabled="page <= 1" @click="$emit('page-change', page - 1)">‹</button>
|
||||||
|
<template v-for="p in visiblePages">
|
||||||
|
<span v-if="p === '...'" :key="p + '-ellipsis'" class="page-ellipsis">…</span>
|
||||||
|
<button v-else :key="p" class="page-btn" :class="{ active: p === page }" @click="$emit('page-change', p)">{{ p }}</button>
|
||||||
|
</template>
|
||||||
|
<button class="page-btn" :disabled="page >= totalPages" @click="$emit('page-change', page + 1)">›</button>
|
||||||
|
<span class="page-info">共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
page: { type: Number, required: true },
|
||||||
|
totalPages: { type: Number, required: true },
|
||||||
|
total: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['page-change'])
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const tp = props.totalPages
|
||||||
|
const cur = props.page
|
||||||
|
if (tp <= 7) return Array.from({ length: tp }, (_, i) => i + 1)
|
||||||
|
const pages = []
|
||||||
|
if (cur <= 4) {
|
||||||
|
pages.push(1, 2, 3, 4, 5, '...', tp)
|
||||||
|
} else if (cur >= tp - 3) {
|
||||||
|
pages.push(1, '...', tp - 4, tp - 3, tp - 2, tp - 1, tp)
|
||||||
|
} else {
|
||||||
|
pages.push(1, '...', cur - 1, cur, cur + 1, '...', tp)
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; }
|
||||||
|
.page-btn { padding: 0.25rem 0.5rem; border: 1px solid #d1d5db; background: white; border-radius: 0.25rem; cursor: pointer; font-size: 0.875rem; }
|
||||||
|
.page-btn:hover:not(:disabled) { background: #f3f4f6; }
|
||||||
|
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.page-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||||
|
.page-ellipsis { padding: 0.25rem 0.25rem; color: #6b7280; }
|
||||||
|
.page-info { font-size: 0.875rem; color: #6b7280; margin-left: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
|
@ -142,7 +142,8 @@ const allItems = computed(() => {
|
||||||
displayContent = JSON.stringify(parsed, null, 2)
|
displayContent = JSON.stringify(parsed, null, 2)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 不是 JSON,保持原样
|
// 不是 JSON,保持原样显示原始内容
|
||||||
|
console.warn('Failed to parse tool result JSON:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
match.resultSummary = displayContent.slice(0, 200)
|
match.resultSummary = displayContent.slice(0, 200)
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,8 @@ class StreamManager {
|
||||||
const data = JSON.parse(line.slice(6))
|
const data = JSON.parse(line.slice(6))
|
||||||
this._handleEvent(conversationId, currentEvent, data, streamStore, null)
|
this._handleEvent(conversationId, currentEvent, data, streamStore, null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略解析错误
|
console.error('SSE parse error in _processLines:', e, 'line:', line)
|
||||||
|
streamStore.errorStream(conversationId, `Parse error: ${e.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,16 @@ export const useStreamStore = defineStore('stream', () => {
|
||||||
|
|
||||||
const idx = state.process_steps.findIndex(s => s.id === step.id)
|
const idx = state.process_steps.findIndex(s => s.id === step.id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
// 对于 thinking/text 步骤,后端发送的是增量内容(基于 offset)
|
||||||
|
// 需要追加到已有内容上,而不是替换
|
||||||
|
if (step.type === 'thinking' || step.type === 'text') {
|
||||||
|
const existing = state.process_steps[idx]
|
||||||
|
existing.content = (existing.content || '') + (step.content || '')
|
||||||
|
// 触发响应式更新
|
||||||
|
state.process_steps[idx] = { ...existing }
|
||||||
|
} else {
|
||||||
state.process_steps[idx] = step
|
state.process_steps[idx] = step
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.process_steps.push(step)
|
state.process_steps.push(step)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="totalPages > 1" class="sidebar-pagination">
|
<Pagination
|
||||||
<button @click="page--; fetchData()" :disabled="page === 1" class="btn-page">‹</button>
|
v-if="totalPages > 1"
|
||||||
<span>{{ page }} / {{ totalPages }}</span>
|
:page="page"
|
||||||
<button @click="page++; fetchData()" :disabled="page >= totalPages" class="btn-page">›</button>
|
:total-pages="totalPages"
|
||||||
</div>
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 右侧内容区 - 对话界面 -->
|
<!-- 右侧内容区 - 对话界面 -->
|
||||||
|
|
@ -170,6 +171,7 @@ import { formatDate } from '../utils/useFormatters.js'
|
||||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||||
import MessageNav from '../components/MessageNav.vue'
|
import MessageNav from '../components/MessageNav.vue'
|
||||||
import MessageBubble from '../components/MessageBubble.vue'
|
import MessageBubble from '../components/MessageBubble.vue'
|
||||||
|
import Pagination from '../components/Pagination.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
list,
|
list,
|
||||||
|
|
@ -277,6 +279,12 @@ const handleRegenerateMessage = async (msgId) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 处理分页切换
|
||||||
|
const handlePageChange = (newPage) => {
|
||||||
|
page.value = newPage
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
const onProviderChange = () => {
|
const onProviderChange = () => {
|
||||||
const p = providers.value.find(p => p.id === form.value.provider_id)
|
const p = providers.value.find(p => p.id === form.value.provider_id)
|
||||||
if (p) form.value.model = p.default_model || ''
|
if (p) form.value.model = p.default_model || ''
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span class="section-icon">🔌</span>
|
<span class="section-icon">🔌</span>
|
||||||
<span class="section-text">LLM Provider</span>
|
<span class="section-text">LLM Provider</span>
|
||||||
|
<span class="section-count">{{ providers.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
|
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
|
||||||
|
|
@ -163,16 +164,17 @@
|
||||||
<table class="settings-table">
|
<table class="settings-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>名称</th>
|
<th>名称 / 类型</th>
|
||||||
<th>API / 模型</th>
|
<th>API / 模型</th>
|
||||||
<th class="switch-col">启用</th>
|
<th class="switch-col">启用</th>
|
||||||
<th class="ops-col">操作</th>
|
<th class="ops-col">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="p in providers" :key="p.id">
|
<tr v-for="p in paginatedProviders" :key="p.id">
|
||||||
<td class="name-col">
|
<td class="name-col">
|
||||||
<div class="provider-name">{{ p.name }}</div>
|
<div class="provider-name">{{ p.name }}</div>
|
||||||
|
<div class="provider-type">{{ p.provider_type }}</div>
|
||||||
<div class="provider-badges">
|
<div class="provider-badges">
|
||||||
<span v-if="p.is_default" class="badge badge-default">默认</span>
|
<span v-if="p.is_default" class="badge badge-default">默认</span>
|
||||||
<span v-if="p.enabled" class="badge badge-enabled">启用</span>
|
<span v-if="p.enabled" class="badge badge-enabled">启用</span>
|
||||||
|
|
@ -180,9 +182,9 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="info-col">
|
<td class="info-col">
|
||||||
<div class="info-item">{{ p.base_url }}</div>
|
<div class="info-item" :title="p.base_url">{{ p.base_url }}</div>
|
||||||
<div class="info-item sub">模型: {{ p.default_model }}</div>
|
<div class="info-item sub">模型: <code>{{ p.default_model }}</code></div>
|
||||||
<div class="info-item sub">最大Tokens: {{ p.max_tokens || 8192 }}</div>
|
<div class="info-item sub">最大 Tokens: {{ p.max_tokens || 8192 }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="switch-col">
|
<td class="switch-col">
|
||||||
<label class="switch" @click.prevent="toggleEnabled(p)">
|
<label class="switch" @click.prevent="toggleEnabled(p)">
|
||||||
|
|
@ -208,7 +210,15 @@
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="table-footer">
|
<td colspan="4" class="table-footer">
|
||||||
<button @click="showModal = true" class="btn-primary">
|
<Pagination
|
||||||
|
v-if="providerTotalPages > 1"
|
||||||
|
:page="providerPage"
|
||||||
|
:total-pages="providerTotalPages"
|
||||||
|
:total="providers.length"
|
||||||
|
@page-change="handleProviderPageChange"
|
||||||
|
class="pagination-inline"
|
||||||
|
/>
|
||||||
|
<button @click="showModal = true; editing = null" class="btn-primary">
|
||||||
<svg width="16" height="16" style="margin-right: 6px;"><use href="#plus-icon"/></svg>
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#plus-icon"/></svg>
|
||||||
添加 Provider
|
添加 Provider
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -240,7 +250,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="u in users" :key="u.id">
|
<tr v-for="u in paginatedUsers" :key="u.id">
|
||||||
<td class="name-col">{{ u.username }}</td>
|
<td class="name-col">{{ u.username }}</td>
|
||||||
<td class="info-col">{{ u.email || '-' }}</td>
|
<td class="info-col">{{ u.email || '-' }}</td>
|
||||||
<td>{{ u.role }}</td>
|
<td>{{ u.role }}</td>
|
||||||
|
|
@ -257,6 +267,19 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<tfoot v-if="userTotalPages > 1">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="table-footer">
|
||||||
|
<Pagination
|
||||||
|
:page="userPage"
|
||||||
|
:total-pages="userTotalPages"
|
||||||
|
:total="users.length"
|
||||||
|
@page-change="handleUserPageChange"
|
||||||
|
class="pagination-inline"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-card">暂无用户</div>
|
<div v-else class="empty-card">暂无用户</div>
|
||||||
|
|
@ -308,23 +331,55 @@
|
||||||
|
|
||||||
<!-- 添加/编辑 Provider 模态框 -->
|
<!-- 添加/编辑 Provider 模态框 -->
|
||||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
<div class="modal">
|
<div class="modal provider-modal">
|
||||||
<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">
|
||||||
<div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/chat/completions" /></div>
|
<label>名称 <span class="required">*</span></label>
|
||||||
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" /></div>
|
<input v-model="form.name" placeholder="如: 我的 DeepSeek" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>模型名称</label>
|
<label>Provider 类型 <span class="required">*</span></label>
|
||||||
|
<select v-model="form.provider_type" class="form-select">
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
<option value="glm">GLM (智谱)</option>
|
||||||
|
<option value="openai-compatible">OpenAI 兼容</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">不同类型的 API 格式和认证方式不同</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Base URL <span class="required">*</span></label>
|
||||||
|
<input v-model="form.base_url" placeholder="https://api.deepseek.com/v1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" />
|
||||||
|
<span v-if="editing" class="hint">留空则不修改已有 Key</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>模型名称 <span class="required">*</span></label>
|
||||||
<input v-model="form.default_model" placeholder="deepseek-chat / gpt-4" required />
|
<input v-model="form.default_model" placeholder="deepseek-chat / gpt-4" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>最大 Tokens</label>
|
<label>最大 Tokens</label>
|
||||||
<input v-model.number="form.max_tokens" type="number" placeholder="8192" min="1" />
|
<input v-model.number="form.max_tokens" type="number" placeholder="8192" min="1" />
|
||||||
<span class="hint">单次回复最大 token 数,默认 8192</span>
|
<span class="hint">单次回复最大 token 数,默认 8192</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input v-model="form.is_default" type="checkbox" />
|
||||||
|
<span>设为默认 Provider</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="formError" class="error">{{ formError }}</div>
|
<div v-if="formError" class="error">{{ formError }}</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|
@ -343,11 +398,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { providersAPI } from '../utils/api.js'
|
import { providersAPI } from '../utils/api.js'
|
||||||
import { useAuth } from '../utils/useAuth.js'
|
import { useAuth } from '../utils/useAuth.js'
|
||||||
import { authAPI } from '../utils/api.js'
|
import { authAPI } from '../utils/api.js'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import Pagination from '../components/Pagination.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { logout, user: currentUser } = useAuth()
|
const { logout, user: currentUser } = useAuth()
|
||||||
|
|
@ -465,8 +521,32 @@ const testing = ref(null)
|
||||||
const testResult = ref(null)
|
const testResult = ref(null)
|
||||||
const formError = ref('')
|
const formError = ref('')
|
||||||
|
|
||||||
|
// Provider 分页
|
||||||
|
const providerPage = ref(1)
|
||||||
|
const providerPageSize = 10
|
||||||
|
const providerTotalPages = computed(() => Math.max(1, Math.ceil(providers.value.length / providerPageSize)))
|
||||||
|
const paginatedProviders = computed(() => {
|
||||||
|
const start = (providerPage.value - 1) * providerPageSize
|
||||||
|
return providers.value.slice(start, start + providerPageSize)
|
||||||
|
})
|
||||||
|
const handleProviderPageChange = (newPage) => {
|
||||||
|
providerPage.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户分页
|
||||||
|
const userPage = ref(1)
|
||||||
|
const userPageSize = 10
|
||||||
|
const userTotalPages = computed(() => Math.max(1, Math.ceil(users.value.length / userPageSize)))
|
||||||
|
const paginatedUsers = computed(() => {
|
||||||
|
const start = (userPage.value - 1) * userPageSize
|
||||||
|
return users.value.slice(start, start + userPageSize)
|
||||||
|
})
|
||||||
|
const handleUserPageChange = (newPage) => {
|
||||||
|
userPage.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192
|
name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', max_tokens: 8192, is_default: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchProviders = async () => {
|
const fetchProviders = async () => {
|
||||||
|
|
@ -504,7 +584,7 @@ const fetchSettings = async () => {
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
editing.value = null
|
editing.value = null
|
||||||
form.value = { name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192 }
|
form.value = { name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', max_tokens: 8192, is_default: false }
|
||||||
formError.value = ''
|
formError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,10 +595,12 @@ const editProvider = async (p) => {
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
form.value = {
|
form.value = {
|
||||||
name: res.data.name,
|
name: res.data.name,
|
||||||
|
provider_type: res.data.provider_type || 'openai',
|
||||||
base_url: res.data.base_url,
|
base_url: res.data.base_url,
|
||||||
api_key: '', // 不显示原密码
|
api_key: '', // 不显示原密码
|
||||||
default_model: res.data.default_model,
|
default_model: res.data.default_model,
|
||||||
max_tokens: res.data.max_tokens || 8192
|
max_tokens: res.data.max_tokens || 8192,
|
||||||
|
is_default: res.data.is_default || false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -663,6 +745,19 @@ onMounted(() => {
|
||||||
.section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; }
|
.section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; }
|
||||||
.section-icon { font-size: 1rem; }
|
.section-icon { font-size: 1rem; }
|
||||||
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
|
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.section-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 内联输入框 */
|
/* 内联输入框 */
|
||||||
.inline-select, .inline-input { padding: 0.5rem 0.75rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; }
|
.inline-select, .inline-input { padding: 0.5rem 0.75rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; }
|
||||||
|
|
@ -678,6 +773,12 @@ onMounted(() => {
|
||||||
|
|
||||||
/* 列宽 */
|
/* 列宽 */
|
||||||
.name-col { width: 15%; min-width: 120px; }
|
.name-col { width: 15%; min-width: 120px; }
|
||||||
|
.provider-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
.info-col { width: 60%; min-width: 200px; }
|
.info-col { width: 60%; min-width: 200px; }
|
||||||
.switch-col { text-align: center; width: 80px; }
|
.switch-col { text-align: center; width: 80px; }
|
||||||
.ops-col { width: 15%; min-width: 180px; text-align: center; }
|
.ops-col { width: 15%; min-width: 180px; text-align: center; }
|
||||||
|
|
@ -692,6 +793,7 @@ onMounted(() => {
|
||||||
|
|
||||||
/* 表格底部 */
|
/* 表格底部 */
|
||||||
.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
|
.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
|
||||||
|
.pagination-inline { display: inline-flex; margin-right: 1rem; border-top: none; padding: 0; background: transparent; }
|
||||||
|
|
||||||
/* Provider 单元格 */
|
/* Provider 单元格 */
|
||||||
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
|
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
|
||||||
|
|
@ -731,6 +833,7 @@ onMounted(() => {
|
||||||
/* 模态框 */
|
/* 模态框 */
|
||||||
.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-primary); border-radius: 16px; padding: 1.25rem; width: 100%; max-width: 400px; }
|
.modal { background: var(--bg-primary); border-radius: 16px; padding: 1.25rem; width: 100%; max-width: 400px; }
|
||||||
|
.provider-modal { max-width: 480px; }
|
||||||
.modal h2 { margin: 0 0 1.25rem; color: var(--text-primary); font-size: 1.1rem; }
|
.modal h2 { margin: 0 0 1.25rem; color: var(--text-primary); font-size: 1.1rem; }
|
||||||
|
|
||||||
.result-modal { text-align: center; }
|
.result-modal { text-align: center; }
|
||||||
|
|
@ -745,9 +848,37 @@ onMounted(() => {
|
||||||
.result-modal .result-json { background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; padding: 0.75rem; margin: 0.75rem 0; max-height: 150px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; }
|
.result-modal .result-json { background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; padding: 0.75rem; margin: 0.75rem 0; max-height: 150px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; }
|
||||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
|
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
|
||||||
.form-group { margin-bottom: 1rem; }
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-group .required { color: var(--danger-color); }
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.checkbox-group { margin-top: 0.5rem; }
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
|
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
|
||||||
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
|
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
|
||||||
.form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; }
|
.form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; }
|
||||||
|
.info-item code {
|
||||||
|
background: var(--bg-code);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
.optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; }
|
.optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; }
|
||||||
.error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; }
|
.error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,8 @@ async def stream_message(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
username=current_user.username,
|
username=current_user.username,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user_permission_level=current_user.permission_level
|
user_permission_level=current_user.permission_level,
|
||||||
|
skip_user_message=True # User message already saved in DB above
|
||||||
):
|
):
|
||||||
# Chat service returns raw SSE strings (including done event)
|
# Chat service returns raw SSE strings (including done event)
|
||||||
yield sse_str
|
yield sse_str
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,29 @@ from pydantic import BaseModel
|
||||||
from luxx.database import get_db
|
from luxx.database import get_db
|
||||||
from luxx.models import User
|
from luxx.models import User
|
||||||
from luxx.routes.auth import get_current_user
|
from luxx.routes.auth import get_current_user
|
||||||
from luxx.tools.core import registry
|
from luxx.tools.core import registry, CommandPermission
|
||||||
from luxx.utils.helpers import success_response
|
from luxx.utils.helpers import success_response
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tools", tags=["Tools"])
|
router = APIRouter(prefix="/tools", tags=["Tools"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_to_detail(tool) -> Dict[str, Any]:
|
||||||
|
"""Convert tool definition to a detailed dict with permission and category info"""
|
||||||
|
# to_openai_format returns {"type": "function", "function": {name, description, parameters}}
|
||||||
|
# Flatten the function properties to top level for easier frontend access
|
||||||
|
base = tool.to_openai_format()
|
||||||
|
func = base.get("function", {})
|
||||||
|
return {
|
||||||
|
"name": func.get("name", tool.name),
|
||||||
|
"description": func.get("description", tool.description),
|
||||||
|
"parameters": func.get("parameters", tool.parameters),
|
||||||
|
"category": tool.category,
|
||||||
|
"required_permission": tool.required_permission.name,
|
||||||
|
"required_permission_level": int(tool.required_permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=dict)
|
@router.get("/", response_model=dict)
|
||||||
def list_tools(
|
def list_tools(
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
|
|
@ -23,17 +39,17 @@ def list_tools(
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
categorized_tools = [t for t in registry._tools.values() if t.category == category]
|
categorized_tools = [t for t in registry._tools.values() if t.category == category]
|
||||||
tools = [t.to_openai_format() for t in categorized_tools]
|
tools = [_tool_to_detail(t) for t in categorized_tools]
|
||||||
else:
|
else:
|
||||||
categorized_tools = list(registry._tools.values())
|
categorized_tools = list(registry._tools.values())
|
||||||
tools = registry.list_all()
|
tools = [_tool_to_detail(t) for t in categorized_tools]
|
||||||
|
|
||||||
categorized = {}
|
categorized = {}
|
||||||
for tool in categorized_tools:
|
for tool in categorized_tools:
|
||||||
cat = tool.category
|
cat = tool.category
|
||||||
if cat not in categorized:
|
if cat not in categorized:
|
||||||
categorized[cat] = []
|
categorized[cat] = []
|
||||||
categorized[cat].append(tool.to_openai_format())
|
categorized[cat].append(_tool_to_detail(tool))
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
|
|
@ -53,7 +69,7 @@ def get_tool(
|
||||||
if not tool:
|
if not tool:
|
||||||
return {"success": False, "message": "Tool not found", "code": 404}
|
return {"success": False, "message": "Tool not found", "code": 404}
|
||||||
|
|
||||||
return success_response(data=tool.to_openai_format())
|
return success_response(data=_tool_to_detail(tool))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{name}/execute", response_model=dict)
|
@router.post("/{name}/execute", response_model=dict)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
This module follows the Single Responsibility Principle.
|
This module follows the Single Responsibility Principle.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, AsyncGenerator
|
from typing import List, Dict, AsyncGenerator
|
||||||
|
|
@ -22,6 +23,7 @@ class AgenticLoop:
|
||||||
|
|
||||||
def __init__(self, tool_executor: ToolExecutor):
|
def __init__(self, tool_executor: ToolExecutor):
|
||||||
self.tool_executor = tool_executor
|
self.tool_executor = tool_executor
|
||||||
|
self._reasoning_content = "" # DeepSeek thinking mode - persists across iterations
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
|
|
@ -37,18 +39,42 @@ class AgenticLoop:
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||||
|
|
||||||
|
# Reset reasoning_content for each execution (DeepSeek thinking mode)
|
||||||
|
self._reasoning_content = ""
|
||||||
|
|
||||||
for iteration in range(MAX_ITERATIONS):
|
for iteration in range(MAX_ITERATIONS):
|
||||||
# Per-iteration reset, keep previous steps and tool results
|
# Per-iteration reset, keep previous steps and tool results
|
||||||
context.reset(full_reset=False)
|
context.reset(full_reset=False)
|
||||||
|
|
||||||
|
# BUG FIX: DeepSeek thinking mode requires reasoning_content to be passed back.
|
||||||
|
# Without this, DeepSeek returns:
|
||||||
|
# "The `reasoning_content` in the thinking mode must be passed back to the API."
|
||||||
|
# We find the last assistant message and add reasoning_content from the previous
|
||||||
|
# iteration's accumulated thinking content.
|
||||||
|
if iteration > 0 and self._reasoning_content:
|
||||||
|
for i in range(len(messages) - 1, -1, -1):
|
||||||
|
if messages[i].get("role") == "assistant":
|
||||||
|
messages[i]["reasoning_content"] = self._reasoning_content
|
||||||
|
break
|
||||||
|
|
||||||
async for delta in llm.stream_call(
|
async for delta in llm.stream_call(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=tools,
|
tools=tools if iteration == 0 else None, # Only send tools on first iteration
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
thinking_enabled=thinking_enabled
|
thinking_enabled=thinking_enabled
|
||||||
):
|
):
|
||||||
|
# Handle error delta - propagate as SSE error event
|
||||||
|
if delta.has_error():
|
||||||
|
logger.error(f"[AGENTIC_LOOP] Stream error: {delta.error_msg}")
|
||||||
|
yield sse_event("error", {"content": delta.error_msg})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Accumulate reasoning_content for DeepSeek thinking mode
|
||||||
|
if delta.has_thinking():
|
||||||
|
self._reasoning_content += delta.thinking
|
||||||
|
|
||||||
events = self._process_delta(delta, context, total_usage)
|
events = self._process_delta(delta, context, total_usage)
|
||||||
for event in events:
|
for event in events:
|
||||||
yield event
|
yield event
|
||||||
|
|
@ -88,6 +114,19 @@ class AgenticLoop:
|
||||||
"total_tokens": delta.usage.get("total_tokens", 0)
|
"total_tokens": delta.usage.get("total_tokens", 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# BUG FIX: Handle DeepSeek reasoning_content (delta.thinking).
|
||||||
|
# DeepSeek sends reasoning_content in separate deltas from normal content.
|
||||||
|
# These must be rendered as thinking steps AND accumulated into
|
||||||
|
# self._reasoning_content for subsequent API calls (line 71-72).
|
||||||
|
# Without this, DeepSeek returns 400:
|
||||||
|
# "The `reasoning_content` in the thinking mode must be passed back to the API."
|
||||||
|
if delta.thinking:
|
||||||
|
if ctx.current_step_type != StepType.THINKING:
|
||||||
|
ctx.start_step(StepType.THINKING)
|
||||||
|
ctx.full_thinking += delta.thinking
|
||||||
|
events.append(StreamRenderer.render_thinking(ctx))
|
||||||
|
ctx._thinking_offset = len(ctx.full_thinking)
|
||||||
|
|
||||||
if delta.content:
|
if delta.content:
|
||||||
result = ctx.process_content(delta.content)
|
result = ctx.process_content(delta.content)
|
||||||
if result["should_emit"]:
|
if result["should_emit"]:
|
||||||
|
|
@ -120,12 +159,30 @@ class AgenticLoop:
|
||||||
"""Execute tools and add results to messages"""
|
"""Execute tools and add results to messages"""
|
||||||
events = []
|
events = []
|
||||||
|
|
||||||
|
# Check if there's an active streaming step that needs to be finalized
|
||||||
|
# before tool execution (e.g. text step started but not finalized)
|
||||||
|
if ctx.current_step_id is not None:
|
||||||
|
ctx.finalize_step()
|
||||||
|
|
||||||
for event in StreamRenderer.render_tool_calls(ctx):
|
for event in StreamRenderer.render_tool_calls(ctx):
|
||||||
events.append(event)
|
events.append(event)
|
||||||
|
|
||||||
|
# Execute tools and handle any exceptions
|
||||||
|
try:
|
||||||
tool_results = self.tool_executor.process_tool_calls_parallel(
|
tool_results = self.tool_executor.process_tool_calls_parallel(
|
||||||
ctx.tool_calls_list, tool_context or {}
|
ctx.tool_calls_list, tool_context or {}
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EXECUTE_TOOLS] Tool execution failed: {type(e).__name__}: {e}")
|
||||||
|
# Generate error results for each tool call
|
||||||
|
tool_results = []
|
||||||
|
for tc in ctx.tool_calls_list:
|
||||||
|
tool_results.append({
|
||||||
|
"tool_call_id": tc.get("id", ""),
|
||||||
|
"role": "tool",
|
||||||
|
"name": tc.get("function", {}).get("name", "unknown"),
|
||||||
|
"content": json.dumps({"success": False, "error": f"Tool execution failed: {type(e).__name__}: {str(e)}"})
|
||||||
|
})
|
||||||
|
|
||||||
# Build mapping from LLM tool_call_id to step id
|
# Build mapping from LLM tool_call_id to step id
|
||||||
# Use index-based matching as fallback when id is empty
|
# Use index-based matching as fallback when id is empty
|
||||||
|
|
@ -146,7 +203,7 @@ class AgenticLoop:
|
||||||
logger.debug(f"[EXECUTE_TOOLS] Matched by id: tc_id={tc_id} -> step_id={ref_id}")
|
logger.debug(f"[EXECUTE_TOOLS] Matched by id: tc_id={tc_id} -> step_id={ref_id}")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Fallback: use index-based matching
|
# Fallback: use index-based matching (most reliable for parallel execution)
|
||||||
if ref_id is None and i < len(tool_call_steps):
|
if ref_id is None and i < len(tool_call_steps):
|
||||||
ref_id = tool_call_steps[i].id
|
ref_id = tool_call_steps[i].id
|
||||||
logger.debug(f"[EXECUTE_TOOLS] Matched by index: i={i} -> step_id={ref_id}")
|
logger.debug(f"[EXECUTE_TOOLS] Matched by index: i={i} -> step_id={ref_id}")
|
||||||
|
|
@ -154,16 +211,31 @@ class AgenticLoop:
|
||||||
# Last resort: generate a step id
|
# Last resort: generate a step id
|
||||||
if ref_id is None:
|
if ref_id is None:
|
||||||
ref_id = f"step-{len(ctx.all_steps) - len(tool_results) + i}"
|
ref_id = f"step-{len(ctx.all_steps) - len(tool_results) + i}"
|
||||||
logger.debug(f"[EXECUTE_TOOLS] Generated ref_id: {ref_id}")
|
logger.warning(f"[EXECUTE_TOOLS] Could not match tool result, generated ref_id: {ref_id}")
|
||||||
|
|
||||||
_, event = StreamRenderer.render_tool_result(ctx, tr, ref_id)
|
_, event = StreamRenderer.render_tool_result(ctx, tr, ref_id)
|
||||||
events.append(event)
|
events.append(event)
|
||||||
|
|
||||||
messages.append({
|
# When tool_calls exist, content should be null (OpenAI API spec)
|
||||||
|
assistant_msg = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": ctx.full_content or "",
|
|
||||||
"tool_calls": ctx.tool_calls_list
|
"tool_calls": ctx.tool_calls_list
|
||||||
})
|
}
|
||||||
|
# When tool_calls exist, content must be null (don't include text before tool calls)
|
||||||
|
if not ctx.tool_calls_list and ctx.full_content and not ctx.full_content.isspace():
|
||||||
|
assistant_msg["content"] = ctx.full_content
|
||||||
|
# If tool_calls exist, omit content field entirely (or set to null)
|
||||||
|
|
||||||
|
# BUG FIX: Include reasoning_content in assistant message for DeepSeek.
|
||||||
|
# DeepSeek's thinking mode REQUIRES reasoning_content to be echoed back
|
||||||
|
# to the API in every subsequent request. Without this, the API returns:
|
||||||
|
# HTTP 400: "The `reasoning_content` in the thinking mode must be
|
||||||
|
# passed back to the API."
|
||||||
|
# This field is accumulated from delta.thinking in the stream loop above.
|
||||||
|
if self._reasoning_content:
|
||||||
|
assistant_msg["reasoning_content"] = self._reasoning_content
|
||||||
|
|
||||||
|
messages.append(assistant_msg)
|
||||||
messages.extend(ctx.all_tool_results[-len(tool_results):])
|
messages.extend(ctx.all_tool_results[-len(tool_results):])
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|
|
||||||
|
|
@ -187,12 +187,15 @@ class ChatService:
|
||||||
user_id: int = None,
|
user_id: int = None,
|
||||||
username: str = None,
|
username: str = None,
|
||||||
workspace: str = None,
|
workspace: str = None,
|
||||||
user_permission_level: int = 1
|
user_permission_level: int = 1,
|
||||||
|
skip_user_message: bool = False # Skip adding user message if already in DB
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Streaming response with Agentic Loop."""
|
"""Streaming response with Agentic Loop."""
|
||||||
try:
|
try:
|
||||||
# Build initial messages
|
# Build initial messages
|
||||||
messages = self.build_messages(conversation)
|
messages = self.build_messages(conversation)
|
||||||
|
# Only add user message if not already in database
|
||||||
|
if not skip_user_message:
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": json.dumps({"text": user_message, "attachments": []})
|
"content": json.dumps({"text": user_message, "attachments": []})
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,15 @@ class OpenAIAdapter(ProviderAdapter):
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = tools
|
body["tools"] = tools
|
||||||
body["tool_choice"] = "auto"
|
body["tool_choice"] = "auto"
|
||||||
|
|
||||||
|
# DeepSeek-specific parameters
|
||||||
|
if "reasoning_effort" in kwargs:
|
||||||
|
body["reasoning_effort"] = kwargs["reasoning_effort"]
|
||||||
|
if "thinking_enabled" in kwargs and kwargs["thinking_enabled"]:
|
||||||
|
body["thinking"] = {"type": "enabled"}
|
||||||
|
if "response_format" in kwargs:
|
||||||
|
body["response_format"] = kwargs["response_format"]
|
||||||
|
|
||||||
return body, headers
|
return body, headers
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
|
|
@ -79,9 +88,23 @@ class OpenAIAdapter(ProviderAdapter):
|
||||||
finish_reason = choice.get("finish_reason")
|
finish_reason = choice.get("finish_reason")
|
||||||
content = delta.get("content", "")
|
content = delta.get("content", "")
|
||||||
|
|
||||||
|
# BUG FIX: Extract DeepSeek reasoning_content (thinking mode).
|
||||||
|
# DeepSeek sends reasoning_content alongside normal content in stream deltas.
|
||||||
|
# This must be yielded as a separate ParsedDelta with only the "thinking" field
|
||||||
|
# set, so the agentic loop can:
|
||||||
|
# 1. Render it as a thinking step in the UI
|
||||||
|
# 2. Accumulate it to echo back in subsequent API calls
|
||||||
|
# Without this, DeepSeek returns 400:
|
||||||
|
# "The `reasoning_content` in the thinking mode must be passed back to the API."
|
||||||
|
reasoning_content = delta.get("reasoning_content", "")
|
||||||
|
|
||||||
# MiniMax may send tool_calls as array in delta
|
# MiniMax may send tool_calls as array in delta
|
||||||
tool_calls = delta.get("tool_calls", [])
|
tool_calls = delta.get("tool_calls", [])
|
||||||
|
|
||||||
|
# Yield thinking (includes DeepSeek reasoning_content)
|
||||||
|
if reasoning_content:
|
||||||
|
yield ParsedDelta(thinking=reasoning_content)
|
||||||
|
|
||||||
# Yield content if present
|
# Yield content if present
|
||||||
if content:
|
if content:
|
||||||
yield ParsedDelta(content=content)
|
yield ParsedDelta(content=content)
|
||||||
|
|
@ -90,8 +113,10 @@ class OpenAIAdapter(ProviderAdapter):
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
yield ParsedDelta(tool_call=tc)
|
yield ParsedDelta(tool_call=tc)
|
||||||
|
|
||||||
# Set is_complete for final chunks (DeepSeek may return null, "length", "content_filter")
|
# Only set is_complete for actual completion ("stop").
|
||||||
if finish_reason and finish_reason not in (None, ""):
|
# DeepSeek sends "tool_calls" when it wants to call tools (stream continues with tool_call deltas).
|
||||||
|
# "length" / "content_filter" mean max_tokens hit - treat as incomplete, let loop handle it.
|
||||||
|
if finish_reason == "stop":
|
||||||
yield ParsedDelta(is_complete=True)
|
yield ParsedDelta(is_complete=True)
|
||||||
|
|
||||||
def parse_response(self, data: Dict) -> Dict:
|
def parse_response(self, data: Dict) -> Dict:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ Usage:
|
||||||
Extending Providers:
|
Extending Providers:
|
||||||
LLMClient.register_adapter("my_provider", MyAdapter)
|
LLMClient.register_adapter("my_provider", MyAdapter)
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Dict, List, Any, Optional, AsyncGenerator, Type
|
from typing import Dict, List, Any, Optional, AsyncGenerator, Type
|
||||||
|
|
@ -284,6 +285,36 @@ class LLMClient:
|
||||||
logger.error(f"Sync call error: {e}\n{traceback.format_exc()}")
|
logger.error(f"Sync call error: {e}\n{traceback.format_exc()}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_error(response: httpx.Response) -> str:
|
||||||
|
"""Extract error message from an API error response.
|
||||||
|
|
||||||
|
Handles various formats: JSON with "error.message", plain text, empty body.
|
||||||
|
This is a static method so it works both with an open stream (aread)
|
||||||
|
and a closed response (text fallback).
|
||||||
|
"""
|
||||||
|
error_body = ""
|
||||||
|
try:
|
||||||
|
error_body_bytes = response.read()
|
||||||
|
if error_body_bytes:
|
||||||
|
error_body = error_body_bytes.decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
error_body = response.text
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if error_body:
|
||||||
|
try:
|
||||||
|
error_json = json.loads(error_body)
|
||||||
|
detail = error_json.get("error", {}).get("message", "") or str(error_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
detail = error_body
|
||||||
|
else:
|
||||||
|
detail = f"HTTP {response.status_code} (no body)"
|
||||||
|
|
||||||
|
return detail[:500]
|
||||||
|
|
||||||
async def stream_call(
|
async def stream_call(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
|
|
@ -316,7 +347,6 @@ class LLMClient:
|
||||||
|
|
||||||
endpoint = self.build_endpoint()
|
endpoint = self.build_endpoint()
|
||||||
logger.info(f"Stream call to {endpoint} with model {model}")
|
logger.info(f"Stream call to {endpoint} with model {model}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
|
|
@ -326,7 +356,16 @@ class LLMClient:
|
||||||
json=body
|
json=body
|
||||||
) as response:
|
) as response:
|
||||||
logger.info(f"Response status: {response.status_code}")
|
logger.info(f"Response status: {response.status_code}")
|
||||||
response.raise_for_status()
|
|
||||||
|
# BUG FIX: Read error body BEFORE raising or closing the stream.
|
||||||
|
# httpx's client.stream() __aexit__ closes the response stream
|
||||||
|
# when an exception occurs, making e.response.aread() return 0 bytes
|
||||||
|
# in the except block below. Reading here (stream still open) fixes this.
|
||||||
|
if response.status_code >= 400:
|
||||||
|
error_msg = self._parse_error(response)
|
||||||
|
logger.error(f"HTTP {response.status_code}: {error_msg}")
|
||||||
|
yield ParsedDelta(error_msg=error_msg)
|
||||||
|
return
|
||||||
|
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
|
@ -351,23 +390,22 @@ class LLMClient:
|
||||||
# Pass clean data to adapter (OpenAIAdapter also handles stripping,
|
# Pass clean data to adapter (OpenAIAdapter also handles stripping,
|
||||||
# but AnthropicAdapter and others need clean JSON input)
|
# but AnthropicAdapter and others need clean JSON input)
|
||||||
async for delta in self.adapter.parse_stream_chunk(event_data):
|
async for delta in self.adapter.parse_stream_chunk(event_data):
|
||||||
if delta.content or delta.has_tool_call() or delta.is_complete or delta.usage:
|
# BUG FIX: Include has_thinking() in filter condition.
|
||||||
|
# DeepSeek sends reasoning_content as separate deltas with only
|
||||||
|
# the "thinking" field populated. Without has_thinking() check,
|
||||||
|
# these deltas were silently dropped, preventing reasoning_content
|
||||||
|
# accumulation and leading to "must be passed back to the API" error.
|
||||||
|
if delta.content or delta.has_thinking() or delta.has_tool_call() or delta.is_complete or delta.usage:
|
||||||
yield delta
|
yield delta
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
status_code = e.response.status_code if e.response else "?"
|
# Fallback: httpx.HTTPStatusError with closed stream
|
||||||
error_body = ""
|
error_msg = self._parse_error(e.response) if e.response else f"HTTP error"
|
||||||
if e.response:
|
logger.error(f"HTTP error (fallback): {error_msg}")
|
||||||
try:
|
yield ParsedDelta(error_msg=error_msg)
|
||||||
await e.response.aread()
|
|
||||||
error_body = e.response.text
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.error(f"HTTP error: {status_code} - {error_body}")
|
|
||||||
yield ParsedDelta()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Stream error: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
logger.error(f"Stream call failed: {type(e).__name__}: {e}")
|
||||||
yield ParsedDelta()
|
yield ParsedDelta(error_msg=f"{type(e).__name__}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# Convenience function
|
# Convenience function
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,12 @@ class Step:
|
||||||
class ParsedDelta:
|
class ParsedDelta:
|
||||||
"""LLM streaming response delta"""
|
"""LLM streaming response delta"""
|
||||||
content: str = ""
|
content: str = ""
|
||||||
thinking: str = ""
|
thinking: str = "" # Includes DeepSeek reasoning_content
|
||||||
text: str = ""
|
text: str = ""
|
||||||
tool_call: Optional[Dict] = None
|
tool_call: Optional[Dict] = None
|
||||||
usage: Dict[str, int] = field(default_factory=dict)
|
usage: Dict[str, int] = field(default_factory=dict)
|
||||||
is_complete: bool = False
|
is_complete: bool = False
|
||||||
|
error_msg: Optional[str] = None
|
||||||
|
|
||||||
def has_thinking(self) -> bool:
|
def has_thinking(self) -> bool:
|
||||||
return bool(self.thinking)
|
return bool(self.thinking)
|
||||||
|
|
@ -58,3 +59,6 @@ class ParsedDelta:
|
||||||
|
|
||||||
def has_content(self) -> bool:
|
def has_content(self) -> bool:
|
||||||
return bool(self.content) or self.has_thinking() or self.has_text() or self.has_tool_call()
|
return bool(self.content) or self.has_thinking() or self.has_text() or self.has_tool_call()
|
||||||
|
|
||||||
|
def has_error(self) -> bool:
|
||||||
|
return self.error_msg is not None
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ class StreamState:
|
||||||
# Track content offset per step to avoid duplication across steps
|
# Track content offset per step to avoid duplication across steps
|
||||||
self._text_offset = 0
|
self._text_offset = 0
|
||||||
self._thinking_offset = 0
|
self._thinking_offset = 0
|
||||||
|
# Track step start offset for finalize_step to compute complete step content
|
||||||
|
self._step_start_text_offset = 0
|
||||||
|
self._step_start_thinking_offset = 0
|
||||||
|
|
||||||
def process_content(self, content: str) -> Dict:
|
def process_content(self, content: str) -> Dict:
|
||||||
"""Process raw content, handling thinking tags."""
|
"""Process raw content, handling thinking tags."""
|
||||||
|
|
@ -172,8 +175,10 @@ class StreamState:
|
||||||
# Record content offset so this step only includes content added during it
|
# Record content offset so this step only includes content added during it
|
||||||
if step_type == StepType.TEXT:
|
if step_type == StepType.TEXT:
|
||||||
self._text_offset = len(self.full_content)
|
self._text_offset = len(self.full_content)
|
||||||
|
self._step_start_text_offset = len(self.full_content)
|
||||||
elif step_type == StepType.THINKING:
|
elif step_type == StepType.THINKING:
|
||||||
self._thinking_offset = len(self.full_thinking)
|
self._thinking_offset = len(self.full_thinking)
|
||||||
|
self._step_start_thinking_offset = len(self.full_thinking)
|
||||||
self.step_index += 1
|
self.step_index += 1
|
||||||
return self.current_step_id
|
return self.current_step_id
|
||||||
|
|
||||||
|
|
@ -190,9 +195,9 @@ class StreamState:
|
||||||
self.current_step_type = None
|
self.current_step_type = None
|
||||||
return
|
return
|
||||||
if self.current_step_type == StepType.TEXT:
|
if self.current_step_type == StepType.TEXT:
|
||||||
content = self.full_content[self._text_offset:]
|
content = self.full_content[self._step_start_text_offset:]
|
||||||
elif self.current_step_type == StepType.THINKING:
|
elif self.current_step_type == StepType.THINKING:
|
||||||
content = self.full_thinking[self._thinking_offset:]
|
content = self.full_thinking[self._step_start_thinking_offset:]
|
||||||
else:
|
else:
|
||||||
content = ""
|
content = ""
|
||||||
step = Step(
|
step = Step(
|
||||||
|
|
@ -211,15 +216,22 @@ class StreamState:
|
||||||
"""Accumulate tool call delta"""
|
"""Accumulate tool call delta"""
|
||||||
idx = tc_delta.get("index", 0)
|
idx = tc_delta.get("index", 0)
|
||||||
if idx >= len(self.tool_calls_list):
|
if idx >= len(self.tool_calls_list):
|
||||||
|
# BUG FIX: DeepSeek may omit "type" in tool_call deltas.
|
||||||
|
# Without defaulting to "function", the tool call would have
|
||||||
|
# type=None, causing JSON serialization issues or API rejections.
|
||||||
|
tc_type = tc_delta.get("type", "function")
|
||||||
self.tool_calls_list.append({
|
self.tool_calls_list.append({
|
||||||
"id": tc_delta.get("id", ""),
|
"id": tc_delta.get("id", ""),
|
||||||
"type": "function",
|
"type": tc_type,
|
||||||
"function": {"name": "", "arguments": ""}
|
"function": {"name": "", "arguments": ""}
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Update id if provided (LLM may send id in a later delta)
|
# Update id if provided (LLM may send id in a later delta)
|
||||||
if tc_delta.get("id"):
|
if tc_delta.get("id"):
|
||||||
self.tool_calls_list[idx]["id"] = tc_delta["id"]
|
self.tool_calls_list[idx]["id"] = tc_delta["id"]
|
||||||
|
# Ensure type field is always present
|
||||||
|
if "type" in tc_delta:
|
||||||
|
self.tool_calls_list[idx]["type"] = tc_delta["type"]
|
||||||
func = tc_delta.get("function", {})
|
func = tc_delta.get("function", {})
|
||||||
if func.get("name"):
|
if func.get("name"):
|
||||||
self.tool_calls_list[idx]["function"]["name"] += func["name"]
|
self.tool_calls_list[idx]["function"]["name"] += func["name"]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Callable, Any, Dict, List, Optional
|
from typing import Callable, Any, Dict, List, Optional
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class CommandPermission(IntEnum):
|
class CommandPermission(IntEnum):
|
||||||
"""Command permission level - higher value means more permissions"""
|
"""Command permission level - higher value means more permissions"""
|
||||||
|
|
@ -98,8 +100,10 @@ class ToolRegistry:
|
||||||
|
|
||||||
def execute(self, name: str, arguments: dict, context: ToolContext = None) -> Dict[str, Any]:
|
def execute(self, name: str, arguments: dict, context: ToolContext = None) -> Dict[str, Any]:
|
||||||
"""Execute tool with optional context and automatic permission check"""
|
"""Execute tool with optional context and automatic permission check"""
|
||||||
|
|
||||||
tool = self.get(name)
|
tool = self.get(name)
|
||||||
if not tool:
|
if not tool:
|
||||||
|
logger.error(f"[REGISTRY] Tool '{name}' not found")
|
||||||
return {"success": False, "error": f"Tool '{name}' not found"}
|
return {"success": False, "error": f"Tool '{name}' not found"}
|
||||||
|
|
||||||
# Automatic permission check (transparent to tool function)
|
# Automatic permission check (transparent to tool function)
|
||||||
|
|
@ -109,22 +113,27 @@ class ToolRegistry:
|
||||||
if isinstance(user_level, int):
|
if isinstance(user_level, int):
|
||||||
user_level = CommandPermission(user_level)
|
user_level = CommandPermission(user_level)
|
||||||
if user_level < tool.required_permission:
|
if user_level < tool.required_permission:
|
||||||
|
logger.warning(f"[REGISTRY] Permission denied for tool '{name}': requires {tool.required_permission.name}, user has {user_level.name}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Permission denied: requires {tool.required_permission.name}, you have {user_level.name}"
|
"error": f"Permission denied: requires {tool.required_permission.name}, you have {user_level.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(f"[REGISTRY] Executing tool '{name}' with args: {arguments}")
|
||||||
try:
|
try:
|
||||||
# Pass context to handler if it accepts it
|
# Pass context to handler if it accepts it
|
||||||
if context is not None:
|
if context is not None:
|
||||||
result = tool.handler(arguments, context=context)
|
result = tool.handler(arguments, context=context)
|
||||||
else:
|
else:
|
||||||
result = tool.handler(arguments)
|
result = tool.handler(arguments)
|
||||||
|
logger.info(f"[REGISTRY] Tool '{name}' executed successfully")
|
||||||
if isinstance(result, ToolResult):
|
if isinstance(result, ToolResult):
|
||||||
return result.to_dict()
|
return result.to_dict()
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)}
|
import traceback
|
||||||
|
logger.error(f"[REGISTRY] Tool '{name}' execution failed: {type(e).__name__}: {str(e)}\n{traceback.format_exc()}")
|
||||||
|
return {"success": False, "error": f"{type(e).__name__}: {str(e)}"}
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all tools"""
|
"""Clear all tools"""
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ This module follows the Single Responsibility Principle:
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
||||||
from luxx.tools.core import registry, ToolContext
|
from luxx.tools.core import registry, ToolContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""Manages tool result caching"""
|
"""Manages tool result caching"""
|
||||||
|
|
@ -165,6 +166,8 @@ class ToolExecutor:
|
||||||
not in completion order. This ensures proper matching between tool_call
|
not in completion order. This ensures proper matching between tool_call
|
||||||
and tool_result steps in the frontend.
|
and tool_result steps in the frontend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
if len(tool_calls) <= 1:
|
if len(tool_calls) <= 1:
|
||||||
return self.process_tool_calls(tool_calls, context)
|
return self.process_tool_calls(tool_calls, context)
|
||||||
|
|
||||||
|
|
@ -201,16 +204,27 @@ class ToolExecutor:
|
||||||
# Wait for all futures and store results at correct indices
|
# Wait for all futures and store results at correct indices
|
||||||
for future in futures_with_index:
|
for future in futures_with_index:
|
||||||
idx, call_id, name, args, cache_key = futures_with_index[future]
|
idx, call_id, name, args, cache_key = futures_with_index[future]
|
||||||
|
try:
|
||||||
result = future.result()
|
result = future.result()
|
||||||
self.cache.set(cache_key, result)
|
self.cache.set(cache_key, result)
|
||||||
self.history.record(name, args, result)
|
self.history.record(name, args, result)
|
||||||
results[idx] = self._create_tool_result(call_id, name, result)
|
results[idx] = self._create_tool_result(call_id, name, result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EXECUTOR] Tool '{name}' execution failed: {type(e).__name__}: {e}")
|
||||||
|
# Create error result
|
||||||
|
error_result = {"success": False, "error": f"{type(e).__name__}: {str(e)}"}
|
||||||
|
self.history.record(name, args, error_result)
|
||||||
|
results[idx] = self._create_tool_result(call_id, name, error_result)
|
||||||
|
|
||||||
# Filter out None values (shouldn't happen, but safety check)
|
# Filter out None values (shouldn't happen, but safety check)
|
||||||
return [r for r in results if r is not None]
|
return [r for r in results if r is not None]
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return self.process_tool_calls(tool_calls, context)
|
return self.process_tool_calls(tool_calls, context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EXECUTOR] Parallel execution failed: {type(e).__name__}: {e}")
|
||||||
|
# Fallback to sequential execution
|
||||||
|
return self.process_tool_calls(tool_calls, context)
|
||||||
|
|
||||||
def _build_tool_context(self, context: Dict[str, Any]) -> ToolContext:
|
def _build_tool_context(self, context: Dict[str, Any]) -> ToolContext:
|
||||||
"""Build ToolContext from context dict"""
|
"""Build ToolContext from context dict"""
|
||||||
|
|
@ -232,11 +246,10 @@ class ToolExecutor:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _create_tool_result(self, call_id: str, name: str, result: Dict) -> Dict[str, Any]:
|
def _create_tool_result(self, call_id: str, name: str, result: Dict) -> Dict[str, Any]:
|
||||||
"""Create tool result message"""
|
"""Create tool result message (OpenAI format: only tool_call_id and content)"""
|
||||||
return {
|
return {
|
||||||
"tool_call_id": call_id,
|
"tool_call_id": call_id,
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"name": name,
|
|
||||||
"content": json.dumps(result, ensure_ascii=False)
|
"content": json.dumps(result, ensure_ascii=False)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue