feat: add supports for selecting provider and models in webchat

This commit is contained in:
Soulter
2025-07-02 17:12:30 +08:00
parent d324cfa84d
commit f3ad53e949
3 changed files with 324 additions and 14 deletions
+24
View File
@@ -9,6 +9,7 @@ from astrbot.core.platform.register import platform_registry
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core import logger
from astrbot.core.provider import Provider
import asyncio
@@ -168,6 +169,7 @@ class ConfigRoute(Route):
"/config/llmtools": ("GET", self.get_llm_tools),
"/config/provider/check_status": ("GET", self.check_all_providers_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_session_seperate": (
"GET",
lambda: Response()
@@ -319,6 +321,28 @@ class ConfigRoute(Route):
provider_list.append(provider)
return Response().ok(provider_list).__dict__
async def get_provider_model_list(self):
"""获取指定提供商的模型列表"""
provider_id = request.args.get("provider_id", None)
if not provider_id:
return Response().error("缺少参数 provider_id").__dict__
prov_mgr = self.core_lifecycle.provider_manager
provider: Provider | None = prov_mgr.inst_map.get(provider_id, None)
if not provider:
return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__
try:
models = await provider.get_models()
ret = {
"models": models,
"provider_id": provider_id,
}
return Response().ok(ret).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def post_astrbot_configs(self):
post_configs = await request.json
try:
+300 -14
View File
@@ -50,10 +50,12 @@
<template v-if="!sidebarCollapsed" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-title-btn"
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="showEditTitleDialog(item.cid, item.title)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" class="delete-conversation-btn"
color="error" @click.stop="deleteConversation(item.cid)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="deleteConversation(item.cid)" />
</div>
</template>
</v-list-item>
@@ -78,7 +80,7 @@
<div class="conversation-header-content" v-if="currCid && getCurrentConversation">
<h2 class="conversation-header-title">{{ getCurrentConversation.title ||
tm('conversation.newConversation')
}}</h2>
}}</h2>
<div class="conversation-header-time">{{ formatDate(getCurrentConversation.updated_at) }}
</div>
</div>
@@ -100,8 +102,8 @@
<!-- 主题切换按钮 -->
<v-tooltip :text="isDark ? tm('modes.lightMode') : tm('modes.darkMode')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon @click="toggleTheme" class="theme-toggle-icon" size="small" rounded="sm" style="margin-right: 8px;"
variant="text">
<v-btn v-bind="props" icon @click="toggleTheme" class="theme-toggle-icon"
size="small" rounded="sm" style="margin-right: 8px;" variant="text">
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</v-btn>
</template>
@@ -188,14 +190,29 @@
<textarea id="input-field" v-model="prompt" @keydown="handleInputKeyDown"
@click:clear="clearMessage" placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: flex-end; margin-top: 8px;">
<v-btn @click="sendMessage" icon="mdi-send" variant="text" color="deep-purple"
:disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl"
class="send-btn" size="small" />
<v-btn @click="isRecording ? stopRecording() : startRecording()"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn" size="small" />
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 8px;">
<div style="display: flex; justify-content: flex-start; margin-top: 8px;">
<!-- 选择提供商和模型 -->
<v-btn class="text-none" variant="tonal" rounded="xl" size="small" v-if="selectedProviderId && selectedModelName" @click="showProviderModelDialog = true">
{{ selectedProviderId }} / {{ selectedModelName }}
</v-btn>
<v-btn variant="tonal" rounded="xl" size="small" v-else @click="showProviderModelDialog = true">
选择模型
</v-btn>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px;">
<v-btn @click="sendMessage" icon="mdi-send" variant="text" color="deep-purple"
:disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl"
class="send-btn" size="small" />
<v-btn @click="isRecording ? stopRecording() : startRecording()"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn"
size="small" />
</div>
</div>
</div>
<!-- 附件预览区 -->
@@ -236,6 +253,89 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- 选择提供商和模型对话框 -->
<v-dialog v-model="showProviderModelDialog" max-width="800" persistent>
<v-card style="padding: 8px;">
<v-card-title class="dialog-title">
<span>选择提供商和模型</span>
</v-card-title>
<v-card-text class="pa-0">
<div class="provider-model-container">
<!-- 左侧提供商列表 -->
<div class="provider-list-panel">
<div class="panel-header">
<h4>提供商</h4>
</div>
<v-list density="compact" nav class="provider-list">
<v-list-item
v-for="provider in chatCompletionProviderConfigs"
:key="provider.id"
:value="provider.id"
@click="selectProvider(provider)"
:active="selectedProviderId === provider.id"
rounded="lg"
class="provider-item">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="chatCompletionProviderConfigs.length === 0" class="empty-state">
<v-icon icon="mdi-cloud-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">暂无可用提供商</div>
</div>
</div>
<!-- 右侧模型列表 -->
<div class="model-list-panel">
<div class="panel-header">
<h4>模型</h4>
<v-btn
v-if="selectedProviderId"
icon="mdi-refresh"
size="small"
variant="text"
@click="refreshModels"
:loading="loadingModels">
</v-btn>
</div>
<v-list density="compact" nav class="model-list" v-if="selectedProviderId">
<v-list-item
v-for="model in modelList"
:key="model"
:value="model"
@click="selectModel(model)"
:active="selectedModelName === model"
rounded="lg"
class="model-item">
<v-list-item-title>{{ model }}</v-list-item-title>
<v-list-item-subtitle v-if="model.description">{{ model.description }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="empty-state">
<v-icon icon="mdi-robot-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">请先选择提供商</div>
</div>
<div v-if="selectedProviderId && modelList.length === 0 && !loadingModels" class="empty-state">
<v-icon icon="mdi-robot-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">该提供商暂无可用模型</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeProviderModelDialog" color="grey-darken-1">取消</v-btn>
<v-btn
text
@click="confirmSelection"
color="primary"
:disabled="!selectedProviderId || !selectedModelName">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
@@ -251,7 +351,7 @@ import 'highlight.js/styles/github.css';
marked.setOptions({
breaks: true,
highlight: function(code, lang) {
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
@@ -327,6 +427,22 @@ export default {
sidebarHoverExpanded: false,
sidebarHoverDelay: 100, // 悬停延迟,单位毫秒
pendingCid: null, // Store pending conversation ID for route handling
chatCompletionProviderConfigs: [],
modelList: [],
selectedProvider: {
id: '',
name: ''
},
selectedModel: {
name: ''
},
// 选择提供商和模型对话框相关变量
showProviderModelDialog: false,
selectedProviderId: '',
selectedModelName: '',
loadingModels: false
}
},
@@ -392,6 +508,7 @@ export default {
this.inputFieldLabel = this.tm('input.chatPrompt');
this.checkStatus();
this.getConversations();
this.getChatCompletionProviderList(); // 获取提供商列表
let inputField = document.getElementById('input-field');
inputField.addEventListener('paste', this.handlePaste);
inputField.addEventListener('keydown', function (e) {
@@ -1036,6 +1153,97 @@ export default {
});
});
},
getChatCompletionProviderList() {
axios.get('/api/config/provider/list', {
params: {
provider_type: 'chat_completion'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.chatCompletionProviderConfigs = response.data.data || [];
} else {
console.error('获取聊天完成提供商列表失败:', response.data.message);
}
})
.catch(error => {
console.error('获取聊天完成提供商列表失败:', error);
});
},
getProviderModels(providerId) {
this.loadingModels = true;
axios.get('/api/config/provider/model_list', {
params: {
provider_id: providerId
}
})
.then(response => {
if (response.data.status === 'ok') {
this.modelList = response.data.data.models || [];
} else {
console.error('获取模型列表失败:', response.data.message);
this.modelList = [];
}
})
.catch(error => {
console.error('获取模型列表失败:', error);
this.modelList = [];
})
.finally(() => {
this.loadingModels = false;
});
},
// 选择提供商
selectProvider(provider) {
this.selectedProviderId = provider.id;
this.selectedModelName = ''; // 重置选中的模型
this.modelList = []; // 清空模型列表
this.getProviderModels(provider.id); // 获取该提供商的模型列表
},
// 选择模型
selectModel(model) {
this.selectedModelName = model;
},
// 刷新模型列表
refreshModels() {
if (this.selectedProviderId) {
this.getProviderModels(this.selectedProviderId);
}
},
// 确认选择
confirmSelection() {
if (this.selectedProviderId && this.selectedModelName) {
// 找到选中的提供商对象
const provider = this.chatCompletionProviderConfigs.find(p => p.id === this.selectedProviderId);
const model = this.modelList.find(m => m.name === this.selectedModelName);
if (provider && model) {
this.selectedProvider = {
id: provider.id,
name: provider.name
};
this.selectedModel = {
name: model.name
};
}
this.closeProviderModelDialog();
}
},
// 关闭对话框
closeProviderModelDialog() {
this.showProviderModelDialog = false;
// 可以选择是否重置临时选择状态
// this.selectedProviderId = '';
// this.selectedModelName = '';
},
},
}
</script>
@@ -1709,4 +1917,82 @@ export default {
flex-shrink: 0;
/* 防止header被压缩 */
}
/* 提供商和模型选择对话框样式 */
.provider-model-container {
display: flex;
height: 500px;
border: 1px solid var(--v-theme-border);
border-radius: 8px;
overflow: hidden;
}
.provider-list-panel,
.model-list-panel {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--v-theme-surface);
}
.provider-list-panel {
border-right: 1px solid var(--v-theme-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--v-theme-border);
background-color: var(--v-theme-containerBg);
}
.panel-header h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--v-theme-primaryText);
}
.provider-list,
.model-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.provider-item,
.model-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
cursor: pointer;
}
.provider-item:hover,
.model-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.provider-item.v-list-item--active,
.model-item.v-list-item--active {
background-color: rgba(103, 58, 183, 0.1);
color: var(--v-theme-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
opacity: 0.6;
gap: 12px;
}
.empty-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
}
</style>