Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter
494771799a style: update font families and improve responsive design across components 2026-04-24 19:26:28 +08:00
Soulter
713b2350d2 stage 2026-04-24 14:21:38 +08:00
22 changed files with 1830 additions and 999 deletions

View File

@@ -766,6 +766,44 @@ class ChatRoute(Route):
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
async def flush_pending_bot_message():
nonlocal message_accumulator, agent_stats, refs
if not (message_accumulator.has_content() or refs or agent_stats):
return None
message_parts_to_save = message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
try:
extracted_refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"Failed to extract web search refs: {e}",
exc_info=True,
)
extracted_refs = refs
saved_record = await self._save_bot_message(
webchat_conv_id,
message_parts_to_save,
agent_stats,
extracted_refs,
llm_checkpoint_id,
platform_history_id,
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
return saved_record
try:
# Emit session_id first so clients can bind the stream immediately.
session_info = {
@@ -885,35 +923,7 @@ class ChatRoute(Route):
should_save = True
if should_save:
message_parts_to_save = (
message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
# 提取 web_search_tavily 引用
try:
refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"Failed to extract web search refs: {e}",
exc_info=True,
)
saved_record = await self._save_bot_message(
webchat_conv_id,
message_parts_to_save,
agent_stats,
refs,
llm_checkpoint_id,
platform_history_id,
)
saved_record = await flush_pending_bot_message()
# 发送保存的消息信息给前端
if saved_record and not client_disconnected:
saved_info = {
@@ -930,15 +940,18 @@ class ChatRoute(Route):
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
except Exception:
pass
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
if msg_type == "end":
break
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
finally:
try:
await flush_pending_bot_message()
except Exception as e:
logger.exception(
f"Failed to persist pending webchat message: {e}",
exc_info=True,
)
webchat_queue_mgr.remove_back_queue(message_id)
# 将消息放入会话特定的队列

View File

@@ -453,6 +453,7 @@ class LiveChatRoute(Route):
llm_checkpoint_id = str(uuid.uuid4())
try:
pending_bot_message_flusher = None
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
await chat_queue.put(
(
@@ -499,9 +500,47 @@ class LiveChatRoute(Route):
agent_stats = {}
refs = {}
async def flush_pending_bot_message():
nonlocal message_accumulator, agent_stats, refs
if not (message_accumulator.has_content() or refs or agent_stats):
return None
message_parts_to_save = message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
try:
extracted_refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"[Live Chat] Failed to extract web search refs: {e}",
exc_info=True,
)
extracted_refs = refs
saved_record = await self._save_bot_message(
session_id,
message_parts_to_save,
agent_stats,
extracted_refs,
llm_checkpoint_id,
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
return saved_record
pending_bot_message_flusher = flush_pending_bot_message
while True:
if session.should_interrupt:
session.should_interrupt = False
await flush_pending_bot_message()
break
try:
@@ -574,30 +613,7 @@ class LiveChatRoute(Route):
should_save = True
if should_save:
message_parts_to_save = message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
try:
refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"[Live Chat] Failed to extract web search refs: {e}",
exc_info=True,
)
saved_record = await self._save_bot_message(
session_id,
message_parts_to_save,
agent_stats,
refs,
llm_checkpoint_id,
)
saved_record = await flush_pending_bot_message()
if saved_record:
await self._send_chat_payload(
session,
@@ -614,10 +630,6 @@ class LiveChatRoute(Route):
},
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
if msg_type == "end":
break
@@ -633,6 +645,14 @@ class LiveChatRoute(Route):
},
)
finally:
try:
if pending_bot_message_flusher is not None:
await pending_bot_message_flusher()
except Exception as e:
logger.exception(
f"[Live Chat] Failed to persist pending chat message: {e}",
exc_info=True,
)
session.is_processing = False
webchat_queue_mgr.remove_back_queue(message_id)

View File

@@ -9,7 +9,7 @@
<meta name="robots" content="noindex, nofollow" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Noto+Sans+SC:wght@100..900&display=swap"
/>
<!-- VAD (Voice Activity Detection) Libraries -->
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/ort.wasm.min.js"></script>

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 247 icons */
/* Auto-generated MDI subset 261 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -236,6 +236,10 @@
content: "\F0167";
}
.mdi-code-braces::before {
content: "\F0169";
}
.mdi-code-json::before {
content: "\F0626";
}
@@ -324,6 +328,10 @@
content: "\F1634";
}
.mdi-database-search-outline::before {
content: "\F1636";
}
.mdi-delete::before {
content: "\F01B4";
}
@@ -396,6 +404,10 @@
content: "\F022E";
}
.mdi-file-delimited-outline::before {
content: "\F0EA5";
}
.mdi-file-document::before {
content: "\F0219";
}
@@ -416,6 +428,10 @@
content: "\F021C";
}
.mdi-file-music-outline::before {
content: "\F0E2A";
}
.mdi-file-outline::before {
content: "\F0224";
}
@@ -436,6 +452,10 @@
content: "\F0A4D";
}
.mdi-file-video-outline::before {
content: "\F0E2C";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -536,6 +556,10 @@
content: "\F0EFE";
}
.mdi-image-outline::before {
content: "\F0976";
}
.mdi-import::before {
content: "\F02FA";
}
@@ -564,10 +588,38 @@
content: "\F0315";
}
.mdi-language-css3::before {
content: "\F031C";
}
.mdi-language-html5::before {
content: "\F031D";
}
.mdi-language-java::before {
content: "\F0B37";
}
.mdi-language-javascript::before {
content: "\F031E";
}
.mdi-language-markdown::before {
content: "\F0354";
}
.mdi-language-markdown-outline::before {
content: "\F0F5B";
}
.mdi-language-python::before {
content: "\F0320";
}
.mdi-language-typescript::before {
content: "\F06E6";
}
.mdi-layers-outline::before {
content: "\F09FE";
}
@@ -688,6 +740,10 @@
content: "\F03D6";
}
.mdi-package-variant-closed::before {
content: "\F03D7";
}
.mdi-page-first::before {
content: "\F0600";
}
@@ -812,10 +868,6 @@
content: "\F167A";
}
.mdi-send::before {
content: "\F048A";
}
.mdi-server::before {
content: "\F048B";
}
@@ -1004,6 +1056,10 @@
content: "\F05B7";
}
.mdi-wrench-outline::before {
content: "\F0BE0";
}
.mdi-zip-box::before {
content: "\F05C4";
}

View File

@@ -1,5 +1,6 @@
<template>
<div
v-if="props.active"
class="chat-ui"
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
>
@@ -34,6 +35,25 @@
</v-btn>
</div>
<v-btn
class="new-chat-btn sidebar-provider-btn"
:class="{
'icon-only': isSidebarCollapsed,
'sidebar-workspace-btn--active': isProviderWorkspace,
}"
variant="text"
:icon="isSidebarCollapsed"
@click="openProviderWorkspace"
>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'mr-2': !isSidebarCollapsed }"
>mdi-creation</v-icon
>
<span v-if="!isSidebarCollapsed">{{ tm("actions.providerConfig") }}</span>
</v-btn>
<v-btn
class="new-chat-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
@@ -66,7 +86,7 @@
v-for="session in sessions"
:key="session.session_id"
class="session-item"
:class="{ active: currSessionId === session.session_id }"
:class="{ active: !isProviderWorkspace && currSessionId === session.session_id }"
role="button"
tabindex="0"
@click="selectSession(session.session_id)"
@@ -243,19 +263,6 @@
</v-card>
</v-menu>
<v-list-item
class="styled-menu-item"
rounded="md"
@click="providerDialog = true"
>
<template #prepend>
<v-icon size="18">mdi-robot-outline</v-icon>
</template>
<v-list-item-title>{{
tm("actions.providerConfig")
}}</v-list-item-title>
</v-list-item>
<v-list-item
class="styled-menu-item"
rounded="md"
@@ -278,12 +285,19 @@
<main
class="chat-main"
:class="{
'empty-chat':
'empty-chat': !isProviderWorkspace &&
!selectedProject && !loadingMessages && !activeMessages.length,
}"
>
<section v-if="isProviderWorkspace" class="provider-workspace-shell">
<ProviderChatCompletionPanel
class="provider-workspace-page"
:show-border="false"
/>
</section>
<ProjectView
v-if="selectedProject"
v-else-if="selectedProject"
:project="selectedProject"
:sessions="projectSessions"
@select-session="selectProjectSession"
@@ -424,7 +438,6 @@
</button>
</div>
<ProviderConfigDialog v-model="providerDialog" />
<ProjectDialog
v-model="projectDialogOpen"
:project="editingProject"
@@ -492,7 +505,6 @@ import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import axios from "axios";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue";
import ProjectDialog, {
type ProjectFormData,
} from "@/components/chat/ProjectDialog.vue";
@@ -516,6 +528,7 @@ import {
import { useMediaHandling } from "@/composables/useMediaHandling";
import { useProjects } from "@/composables/useProjects";
import { useCustomizerStore } from "@/stores/customizer";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import {
useI18n,
useLanguageSwitcher,
@@ -525,8 +538,9 @@ import type { Locale } from "@/i18n/types";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import { useToast } from "@/utils/toast";
const props = withDefaults(defineProps<{ chatboxMode?: boolean }>(), {
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
chatboxMode: false,
active: true,
});
const route = useRoute();
@@ -574,8 +588,10 @@ const {
cleanupMediaCache,
} = useMediaHandling();
type WorkspaceView = "chat" | "providers";
const sidebarCollapsed = ref(false);
const providerDialog = ref(false);
const activeWorkspace = ref<WorkspaceView>("chat");
const projectDialogOpen = ref(false);
const editingProject = ref<Project | null>(null);
const sessionTitleDialogOpen = ref(false);
@@ -630,6 +646,9 @@ const chatSidebarDrawer = computed({
const isSidebarCollapsed = computed(() =>
lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen,
);
const isProviderWorkspace = computed(
() => activeWorkspace.value === "providers",
);
const activeReasoningParts = computed<MessagePart[]>(() => {
if (!activeReasoningTarget.value) return [];
const blocks = buildMessageBlocks(
@@ -734,7 +753,9 @@ onMounted(async () => {
try {
await Promise.all([getSessions(), getProjects()]);
const routeSessionId = getRouteSessionId();
if (routeSessionId) {
if (routeSessionId === "models") {
activeWorkspace.value = "providers";
} else if (routeSessionId) {
await selectSession(routeSessionId, false);
}
} finally {
@@ -750,10 +771,16 @@ watch(
() => route.params.conversationId,
async () => {
const routeSessionId = getRouteSessionId();
if (routeSessionId === "models") {
activeWorkspace.value = "providers";
return;
}
if (routeSessionId && routeSessionId !== currSessionId.value) {
showChatWorkspace();
selectedProjectId.value = null;
await selectSession(routeSessionId, false);
} else if (!routeSessionId && currSessionId.value) {
showChatWorkspace();
currSessionId.value = "";
}
},
@@ -780,11 +807,36 @@ function closeMobileSidebar() {
}
}
function closeSecondaryPanels() {
threadSelection.visible = false;
threadPanelOpen.value = false;
activeThread.value = null;
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
selectedRefs.value = null;
}
function showChatWorkspace() {
activeWorkspace.value = "chat";
}
async function openProviderWorkspace() {
closeSecondaryPanels();
activeWorkspace.value = "providers";
const targetPath = `${basePath()}/models`;
if (route.path !== targetPath) {
await router.push(targetPath);
}
closeMobileSidebar();
}
function sessionTitle(session: Session) {
return session.display_name?.trim() || tm("conversation.newConversation");
}
async function startNewChat() {
showChatWorkspace();
selectedProjectId.value = null;
replyTarget.value = null;
newChat();
@@ -802,6 +854,7 @@ function openEditProjectDialog(project: Project) {
}
async function selectProject(projectId: string) {
showChatWorkspace();
selectedProjectId.value = projectId;
currSessionId.value = "";
replyTarget.value = null;
@@ -910,6 +963,7 @@ async function saveProject(formData: ProjectFormData, projectId?: string) {
}
async function selectSession(sessionId: string, pushRoute = true) {
showChatWorkspace();
selectedProjectId.value = null;
currSessionId.value = sessionId;
replyTarget.value = null;
@@ -1375,6 +1429,10 @@ function toggleTheme() {
font-weight: 500;
}
.sidebar-provider-btn {
margin-bottom: 8px;
}
.new-chat-btn:not(.icon-only),
.settings-btn:not(.icon-only) {
padding-inline: 12px;
@@ -1400,6 +1458,11 @@ function toggleTheme() {
background: var(--chat-session-active-bg);
}
.sidebar-workspace-btn--active {
background: var(--chat-session-active-bg);
color: rgb(var(--v-theme-on-surface));
}
.chevron-collapsed {
transform: rotate(180deg);
}
@@ -1512,6 +1575,17 @@ function toggleTheme() {
justify-content: center;
}
.provider-workspace-shell {
flex: 1;
min-height: 0;
overflow: hidden;
}
.provider-workspace-page {
height: 100%;
min-height: 0;
}
.messages-panel {
flex: 1;
min-height: 0;

View File

@@ -1,138 +1,22 @@
<template>
<v-dialog v-model="dialog" :max-width="isMobile ? undefined : '1400'" :fullscreen="isMobile" scrollable>
<v-card class="provider-config-dialog" :class="{ 'mobile-dialog': isMobile }">
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-0">
<div class="d-flex align-center ga-2">
<span class="text-h2 font-weight-bold">{{ tm('title') }}</span>
</div>
<v-btn icon variant="text" @click="closeDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4 pt-0" :class="{ 'mobile-content': isMobile }"
:style="isMobile ? {} : { height: 'calc(100vh - 200px); max-height: 800px;' }">
<div :class="isMobile ? 'mobile-layout' : 'd-flex'" :style="isMobile ? {} : { height: '100%' }">
<!-- 左侧Provider Sources 列表 -->
<div class="provider-sources-column" :class="{ 'mobile-sources': isMobile }"
:style="isMobile ? {} : { width: '320px', minWidth: '320px', borderRight: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))', overflowY: 'auto' }">
<ProviderSourcesPanel :displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource" :available-source-types="availableSourceTypes" :tm="tm"
:resolve-source-icon="resolveSourceIcon" :get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource" @select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource" />
</div>
<!-- 右侧配置和模型 -->
<div class="provider-config-column" :class="{ 'mobile-config': isMobile }"
:style="isMobile ? {} : { flex: 1, overflowY: 'auto', minWidth: 0 }">
<div v-if="selectedProviderSource" class="pa-4">
<!-- Provider Source 配置 -->
<div class="mb-4">
<div class="d-flex align-center justify-space-between mb-3">
<div>
<div class="text-h5 font-weight-bold">{{ selectedProviderSource.id }}</div>
<div class="text-caption text-medium-emphasis">{{ selectedProviderSource.api_base || 'N/A' }}</div>
</div>
<v-btn color="success" prepend-icon="mdi-check" :loading="savingSource" :disabled="!isSourceModified"
@click="saveProviderSource" variant="flat">
{{ tm('providerSources.save') }}
</v-btn>
</div>
<!-- 基础配置 -->
<div class="mb-4">
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
</div>
<!-- 高级配置 -->
<v-expansion-panels variant="accordion" class="mb-4">
<v-expansion-panel elevation="0" class="border rounded-lg">
<v-expansion-panel-title>
<span class="font-weight-medium">{{ tm('providerSources.advancedConfig') }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<AstrBotConfig v-if="advancedSourceConfig" :iterable="advancedSourceConfig"
:metadata="configSchema" metadataKey="provider" :is-editing="true" />
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<!-- 模型配置 -->
<ProviderModelsPanel :entries="filteredMergedModelEntries" :available-count="availableModels.length"
v-model:model-search="modelSearch" :loading-models="loadingModels"
:is-source-modified="isSourceModified" :supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall" :supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit" :testing-providers="testingProviders" :tm="tm"
@fetch-models="fetchAvailableModels" @open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit" @toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider" @delete-provider="deleteProvider"
@add-model-provider="addModelProvider" />
</div>
</div>
<div v-else class="d-flex align-center justify-center" style="height: 100%;">
<div class="text-center text-medium-emphasis">
<v-icon size="64" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-4 text-h6">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</div>
</div>
</v-card-text>
<v-dialog
v-model="dialog"
max-width="1600"
>
<v-card class="provider-config-dialog">
<div class="provider-config-dialog__body">
<ProviderChatCompletionPanel
class="provider-config-dialog__page"
:show-border="false"
/>
</div>
</v-card>
<!-- 手动添加模型对话框 -->
<v-dialog v-model="showManualModelDialog" max-width="400">
<v-card :title="tm('models.manualDialogTitle')">
<v-card-text class="py-4">
<v-text-field v-model="manualModelId" :label="tm('models.manualDialogModelLabel')" flat variant="solo-filled"
autofocus clearable></v-text-field>
<v-text-field :model-value="manualProviderId" flat variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')" persistent-hint
:hint="tm('models.manualDialogPreviewHint')"></v-text-field>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn>
<v-btn color="primary" @click="confirmManualModel">添加</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 已配置模型编辑对话框 -->
<v-dialog v-model="showProviderEditDialog" width="800">
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效</small>
<AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderEditDialog = false"
:disabled="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<script setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
import { useProviderSources } from '@/composables/useProviderSources'
import { getProviderIcon } from '@/utils/providerUtils'
import axios from 'axios'
import { computed } from 'vue'
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
const props = defineProps({
modelValue: {
@@ -142,236 +26,73 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('features/provider')
// 检测是否为手机端
const isMobile = ref(false)
function checkMobile() {
isMobile.value = window.innerWidth <= 768
}
const dialog = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
function showMessage(message, color = 'success') {
snackbar.value = { show: true, message, color }
}
const {
selectedProviderSource,
availableModels,
loadingModels,
savingSource,
testingProviders,
isSourceModified,
configSchema,
manualModelId,
modelSearch,
availableSourceTypes,
displayedProviderSources,
filteredMergedModelEntries,
basicSourceConfig,
advancedSourceConfig,
manualProviderId,
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsAudioInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
selectProviderSource,
addProviderSource,
deleteProviderSource,
saveProviderSource,
fetchAvailableModels,
addModelProvider,
deleteProvider,
testProvider,
loadConfig,
modelAlreadyConfigured,
} = useProviderSources({
defaultTab: 'chat_completion',
tm,
showMessage
})
const showManualModelDialog = ref(false)
const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const savingProviders = ref([])
function closeDialog() {
dialog.value = false
}
function openManualModelDialog() {
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
manualModelId.value = ''
showManualModelDialog.value = true
}
async function confirmManualModel() {
const modelId = manualModelId.value.trim()
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
if (!modelId) {
showMessage(tm('models.manualModelRequired'), 'error')
return
}
if (modelAlreadyConfigured(modelId)) {
showMessage(tm('models.manualModelExists'), 'error')
return
}
await addModelProvider(modelId)
showManualModelDialog.value = false
}
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
providerEditOriginalId.value = provider.id
showProviderEditDialog.value = true
}
async function saveEditedProvider() {
if (!providerEditData.value) return
savingProviders.value.push(providerEditData.value.id)
try {
const res = await axios.post('/api/config/provider/update', {
id: providerEditOriginalId.value || providerEditData.value.id,
config: providerEditData.value
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('providerSources.saveSuccess'))
showProviderEditDialog.value = false
await loadConfig()
} catch (err) {
showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error')
} finally {
savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id)
}
}
async function toggleProviderEnable(provider, value) {
provider.enable = value
try {
const res = await axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('messages.success.statusUpdate'))
} catch (error) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
} finally {
await loadConfig()
}
}
// 监听 dialog 打开,加载配置
watch(dialog, (newVal) => {
if (newVal) {
loadConfig()
checkMobile()
}
})
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.provider-config-dialog {
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
}
.provider-config-dialog.mobile-dialog {
height: 100vh;
}
.provider-sources-column {
overflow-y: auto;
background-color: var(--v-theme-surface);
}
.provider-config-column {
background-color: var(--v-theme-background);
}
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
/* 手机端样式 */
.mobile-content {
padding: 8px !important;
padding-top: 0 !important;
height: calc(100vh - 64px) !important;
max-height: none !important;
}
.mobile-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: 16px;
max-height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 28px;
}
.mobile-sources {
width: 100% !important;
min-width: 100% !important;
border-right: none !important;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
max-height: 40vh;
overflow-y: auto;
}
.mobile-config {
.provider-config-dialog__body {
flex: 1;
overflow-y: auto;
min-width: 100% !important;
display: flex;
min-height: 0;
overflow: hidden;
}
@media (max-width: 768px) {
.provider-config-dialog :deep(.v-card-title) {
padding: 12px 16px !important;
.provider-config-dialog__page {
flex: 1;
display: flex;
width: 100%;
height: 100%;
min-height: 0;
}
:deep(.v-overlay__content) {
width: min(1600px, 70vw);
height: min(920px, 70dvh);
max-width: 70vw;
max-height: 70dvh;
margin: 0;
}
@media (max-width: 960px) {
.provider-config-dialog {
border-radius: 20px;
}
.provider-config-dialog :deep(.v-card-title .text-h2) {
font-size: 1.5rem !important;
:deep(.v-overlay__content) {
width: calc(100dvw - 24px);
height: calc(100dvh - 24px);
max-width: calc(100dvw - 24px);
max-height: calc(100dvh - 24px);
}
}
@media (max-width: 600px) {
.provider-config-dialog {
border-radius: 0;
}
.provider-config-dialog__body {
overflow: auto;
}
:deep(.v-overlay__content) {
width: 100dvw;
height: 100dvh;
max-width: 100dvw;
max-height: 100dvh;
}
}
</style>

View File

@@ -0,0 +1,496 @@
<template>
<div class="provider-chat-panel">
<div
class="provider-workbench"
:class="{ 'provider-workbench--borderless': !props.showBorder }"
>
<div class="provider-workbench__sidebar">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
:available-source-types="availableSourceTypes"
:tm="tm"
:resolve-source-icon="resolveSourceIcon"
:get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource"
@select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource"
/>
</div>
<div class="provider-workbench__divider"></div>
<div class="provider-workbench__main">
<div v-if="selectedProviderSource" class="provider-config-shell">
<div class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
variant="tonal"
rounded="xl"
@click="saveProviderSource"
>
{{ tm('providerSources.save') }}
</v-btn>
</div>
</div>
<v-divider></v-divider>
<div class="provider-config-body">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
<AstrBotConfig
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider v-if="advancedSourceConfig"></v-divider>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider></v-divider>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
</div>
</div>
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</div>
<v-dialog v-model="showManualModelDialog" max-width="400">
<v-card :title="tm('models.manualDialogTitle')">
<v-card-text class="py-4">
<v-text-field
v-model="manualModelId"
:label="tm('models.manualDialogModelLabel')"
flat
variant="solo-filled"
autofocus
clearable
></v-text-field>
<v-text-field
:model-value="manualProviderId"
flat
variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')"
persistent-hint
:hint="tm('models.manualDialogPreviewHint')"
></v-text-field>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn>
<v-btn color="primary" @click="confirmManualModel">添加</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showProviderEditDialog" width="800">
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效旧版本 AstrBot 提供商 ID 是下方的 ID</small>
<AstrBotConfig
v-if="providerEditData"
:iterable="providerEditData"
:metadata="configSchema"
metadataKey="provider"
:is-editing="true"
/>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn
variant="text"
:disabled="savingProviders.includes(providerEditData?.id)"
@click="showProviderEditDialog = false"
>
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn
color="primary"
:loading="savingProviders.includes(providerEditData?.id)"
@click="saveEditedProvider"
>
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="top">
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
import { useProviderSources } from '@/composables/useProviderSources'
const props = defineProps({
showBorder: {
type: Boolean,
default: true
}
})
const { tm } = useModuleI18n('features/provider')
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
function showMessage(message, color = 'success') {
snackbar.value = { show: true, message, color }
}
const {
selectedProviderSource,
availableModels,
loadingModels,
savingSource,
testingProviders,
isSourceModified,
configSchema,
providerSourceSchema,
manualModelId,
modelSearch,
availableSourceTypes,
displayedProviderSources,
filteredMergedModelEntries,
basicSourceConfig,
advancedSourceConfig,
manualProviderId,
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsAudioInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
selectProviderSource,
addProviderSource,
deleteProviderSource,
saveProviderSource,
fetchAvailableModels,
addModelProvider,
deleteProvider,
testProvider,
toggleProviderEnable,
loadConfig,
modelAlreadyConfigured
} = useProviderSources({
defaultTab: 'chat_completion',
tm,
showMessage
})
const showManualModelDialog = ref(false)
const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const savingProviders = ref([])
function openManualModelDialog() {
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
manualModelId.value = ''
showManualModelDialog.value = true
}
async function confirmManualModel() {
const modelId = manualModelId.value.trim()
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
if (!modelId) {
showMessage(tm('models.manualModelRequired'), 'error')
return
}
if (modelAlreadyConfigured(modelId)) {
showMessage(tm('models.manualModelExists'), 'error')
return
}
await addModelProvider(modelId)
showManualModelDialog.value = false
}
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
providerEditOriginalId.value = provider.id
showProviderEditDialog.value = true
}
async function saveEditedProvider() {
if (!providerEditData.value) return
savingProviders.value.push(providerEditData.value.id)
try {
const res = await axios.post('/api/config/provider/update', {
id: providerEditOriginalId.value || providerEditData.value.id,
config: providerEditData.value
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('providerSources.saveSuccess'))
showProviderEditDialog.value = false
await loadConfig()
} catch (err) {
showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error')
} finally {
savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id)
}
}
</script>
<style scoped>
.provider-chat-panel {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.provider-workbench {
flex: 1;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 24px;
background: rgb(var(--v-theme-surface));
display: grid;
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.provider-workbench--borderless {
border: 0;
border-radius: 0;
background: transparent;
}
.provider-workbench__sidebar,
.provider-workbench__main {
min-width: 0;
min-height: 0;
}
.provider-workbench__sidebar,
.provider-workbench__main,
.provider-workbench__divider {
height: 100%;
}
.provider-workbench__divider {
background: rgba(var(--v-theme-on-surface), 0.08);
}
.provider-workbench__main {
display: flex;
overflow: hidden;
}
.provider-config-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.provider-config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 18px 22px 14px;
}
.provider-config-headline {
min-width: 0;
}
.provider-config-title {
font-size: 21px;
line-height: 1.1;
font-weight: 680;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.provider-config-subtitle {
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.provider-config-actions {
flex-shrink: 0;
}
.provider-config-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.provider-section {
padding: 18px 22px;
}
.provider-section--models {
padding-top: 16px;
}
.provider-section-head {
margin-bottom: 10px;
}
.provider-section-title {
font-size: 16px;
font-weight: 650;
line-height: 1.4;
}
.provider-empty-state {
flex: 1;
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.56);
}
@media (max-width: 960px) {
.provider-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto 1px minmax(0, 1fr);
}
.provider-workbench__sidebar,
.provider-workbench__main,
.provider-workbench__divider {
height: auto;
}
.provider-workbench__divider {
min-height: 1px;
}
.provider-config-header {
align-items: stretch;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.provider-config-actions :deep(.v-btn) {
width: 100%;
}
.provider-section {
padding: 16px;
}
}
@media (max-width: 600px) {
.provider-chat-panel {
overflow: auto;
}
.provider-workbench {
border-radius: 16px;
overflow: visible;
}
.provider-workbench--borderless {
border-radius: 0;
}
.provider-workbench__main {
overflow: visible;
}
.provider-config-body {
overflow-y: visible;
}
.provider-config-title {
font-size: 18px;
}
.provider-empty-state {
min-height: 260px;
padding: 24px;
}
}
</style>

View File

@@ -1,37 +1,40 @@
<template>
<div class="provider-models-panel">
<div class="provider-models-head">
<div class="provider-models-toolbar">
<div class="provider-models-title-wrap">
<h3 class="provider-models-title">{{ tm('models.configured') }}</h3>
<small v-if="availableCount" class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
<h3 class="provider-models-title">{{ tm('models.title') }}</h3>
<small class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
</div>
<v-text-field
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
class="provider-models-search"
:placeholder="tm('models.searchPlaceholder')"
/>
<div class="provider-models-actions">
<div class="provider-models-toolbar__actions">
<v-text-field
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
class="provider-models-search"
:placeholder="tm('models.searchPlaceholder')"
/>
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
rounded="xl"
@click="emit('fetch-models')"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
rounded="xl"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
@@ -39,130 +42,152 @@
</div>
</div>
<v-list
density="compact"
class="provider-models-list"
>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
<v-tooltip location="top" max-width="400" v-if="entry.type === 'configured'">
<template #activator="{ props }">
<v-list-item
v-bind="props"
class="provider-compact-item"
@click="emit('open-provider-edit', entry.provider)"
>
<v-list-item-title class="font-weight-medium text-truncate">
{{ entry.provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<span>{{ entry.provider.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(entry.metadata)" size="14" color="grey">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1" @click.stop>
<v-switch
v-model="entry.provider.enable"
density="compact"
inset
hide-details
color="primary"
class="mr-1"
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-tooltip location="top" max-width="300">
{{ tm('availability.test') }}
<template #activator="{ props }">
<v-btn
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:loading="isProviderTesting(entry.provider.id)"
v-bind="props"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
</template>
</v-tooltip>
<div class="provider-models-sections">
<section class="provider-models-section">
<div class="provider-models-section__head">
<div class="provider-models-section__title">{{ tm('models.configured') }}</div>
<v-chip size="x-small" variant="tonal" label>{{ configuredEntries.length }}</v-chip>
</div>
<v-tooltip location="top" max-width="300">
{{ tm('models.configure') }}
<template #activator="{ props }">
<v-btn
icon="mdi-cog"
size="small"
variant="text"
v-bind="props"
@click.stop="emit('open-provider-edit', entry.provider)"
></v-btn>
</template>
</v-tooltip>
<div v-if="configuredEntries.length" class="provider-models-list">
<v-tooltip
v-for="entry in configuredEntries"
:key="entry.provider.id"
location="top"
max-width="400"
>
<template #activator="{ props: tooltipProps }">
<div v-bind="tooltipProps" class="provider-model-row">
<button
type="button"
class="provider-model-row__main"
@click="emit('open-provider-edit', entry.provider)"
>
<div class="provider-model-row__title">{{ entry.provider.id }}</div>
<div class="provider-model-row__subtitle">{{ entry.provider.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
>
{{ formatContextLimit(entry.metadata) }}
</span>
</div>
</button>
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
<div class="provider-model-row__actions" @click.stop>
<v-switch
v-model="entry.provider.enable"
density="compact"
inset
hide-details
color="primary"
class="provider-model-row__switch"
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-btn
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:loading="isProviderTesting(entry.provider.id)"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
<v-btn
icon="mdi-cog-outline"
size="small"
variant="text"
@click.stop="emit('open-provider-edit', entry.provider)"
></v-btn>
<v-btn
icon="mdi-delete-outline"
size="small"
variant="text"
@click.stop="emit('delete-provider', entry.provider)"
></v-btn>
</div>
</div>
</template>
</v-list-item>
</template>
<div>
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
</div>
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
</v-tooltip>
<v-tooltip location="top" max-width="400" v-else>
<template #activator="{ props }">
<v-list-item v-bind="props" class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
<v-list-item-title>{{ entry.model }}</v-list-item-title>
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<span>{{ entry.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(entry.metadata)" size="14" color="grey">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
</template>
</v-list-item>
</template>
<div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
</div>
</v-tooltip>
</template>
</template>
<template v-else>
<div class="text-center pa-4 text-medium-emphasis">
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
</div>
</template>
</v-list>
<div v-else class="provider-models-empty">
<v-icon size="36" color="grey-lighten-1">mdi-package-variant-closed</v-icon>
<p>{{ tm('models.empty') }}</p>
</div>
</section>
<v-divider></v-divider>
<section class="provider-models-section provider-models-section--available">
<div class="provider-models-section__head">
<div class="provider-models-section__title">{{ tm('models.available') }}</div>
<v-chip size="x-small" variant="tonal" label>{{ availableEntries.length }}</v-chip>
</div>
<div v-if="availableEntries.length" class="provider-models-list">
<v-tooltip
v-for="entry in availableEntries"
:key="entry.model"
location="top"
max-width="400"
>
<template #activator="{ props: tooltipProps }">
<div v-bind="tooltipProps" class="provider-model-row">
<button
type="button"
class="provider-model-row__main"
@click="emit('add-model-provider', entry.model)"
>
<div class="provider-model-row__title provider-model-row__title--mono">{{ entry.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
>
{{ formatContextLimit(entry.metadata) }}
</span>
</div>
</button>
<div class="provider-model-row__actions">
<v-btn
icon="mdi-plus"
size="small"
variant="text"
color="primary"
@click.stop="emit('add-model-provider', entry.model)"
></v-btn>
</div>
</div>
</template>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
</v-tooltip>
</div>
<div v-else class="provider-models-empty provider-models-empty--small">
<v-icon size="36" color="grey-lighten-1">mdi-database-search-outline</v-icon>
<p>{{ tm('models.noModelsFound') }}</p>
</div>
</section>
</div>
</div>
</template>
@@ -237,91 +262,266 @@ const modelSearchProxy = computed({
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
})
const configuredEntries = computed(() =>
(props.entries || []).filter((entry) => entry.type === 'configured')
)
const availableEntries = computed(() =>
(props.entries || []).filter((entry) => entry.type === 'available')
)
const capabilityIcons = (metadata) => {
const icons = []
if (props.supportsImageInput(metadata)) {
icons.push({ icon: 'mdi-image-outline' })
}
if (props.supportsAudioInput(metadata)) {
icons.push({ icon: 'mdi-music-note-outline' })
}
if (props.supportsToolCall(metadata)) {
icons.push({ icon: 'mdi-wrench-outline' })
}
if (props.supportsReasoning(metadata)) {
icons.push({ icon: 'mdi-brain' })
}
return icons
}
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
</script>
<style scoped>
.provider-models-panel {
display: grid;
gap: 14px;
gap: 18px;
}
.provider-models-head {
.provider-models-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.provider-models-title-wrap {
min-width: 0;
justify-content: space-between;
gap: 12px;
flex-wrap: nowrap;
}
.provider-models-title {
margin: 0;
font-size: 18px;
line-height: 1.3;
font-weight: 650;
line-height: 1.3;
}
.provider-models-title-wrap {
min-width: 0;
flex-shrink: 0;
}
.provider-models-subtitle {
display: block;
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.6);
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
}
.provider-models-search {
max-width: 240px;
.provider-models-toolbar__actions {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
justify-content: flex-end;
flex-wrap: nowrap;
}
.provider-models-actions {
margin-left: auto;
.provider-models-search {
flex: 0 1 240px;
min-width: 180px;
max-width: 260px;
}
.provider-models-sections {
display: flex;
flex-direction: column;
}
.provider-models-section {
padding: 4px 0;
}
.provider-models-section--available {
padding-top: 22px;
}
.provider-models-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.provider-models-section__title {
font-size: 14px;
font-weight: 650;
}
.provider-models-list {
display: flex;
flex-direction: column;
}
.provider-model-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.06);
}
.provider-model-row:last-child {
border-bottom: 0;
}
.provider-model-row__main {
flex: 1;
min-width: 0;
border: 0;
background: none;
color: inherit;
padding: 0;
text-align: left;
cursor: pointer;
}
.provider-model-row__title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.provider-model-row__title--mono {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
"Courier New",
monospace;
}
.provider-model-row__subtitle {
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.provider-model-row__meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.provider-models-list {
max-height: 520px;
overflow-y: auto;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 14px;
background: rgb(var(--v-theme-surface));
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
.provider-model-row__badge {
width: 24px;
height: 24px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.58);
display: inline-flex;
align-items: center;
justify-content: center;
}
.provider-compact-item {
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
.provider-model-row__badge--text {
width: auto;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
}
.provider-models-list :deep(.v-list-item:last-child) {
border-bottom: 0;
.provider-model-row__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.provider-model-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
.provider-model-row__switch {
margin-right: 2px;
}
.cursor-pointer {
cursor: pointer;
.provider-models-empty {
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: rgba(var(--v-theme-on-surface), 0.56);
text-align: center;
font-size: 13px;
}
@media (max-width: 900px) {
.provider-models-head {
.provider-models-empty--small {
min-height: 120px;
}
@media (max-width: 760px) {
.provider-models-toolbar {
align-items: stretch;
flex-direction: column;
}
.provider-models-title-wrap {
flex-shrink: 1;
}
.provider-models-toolbar__actions {
align-items: stretch;
justify-content: stretch;
flex-wrap: wrap;
}
.provider-models-search {
flex: 1 1 100%;
min-width: 0;
max-width: none;
width: 100%;
}
.provider-models-actions {
margin-left: 0;
width: 100%;
.provider-models-toolbar__actions :deep(.v-btn) {
flex: 1 1 160px;
min-width: 0;
}
.provider-models-toolbar__actions :deep(.v-btn__content) {
white-space: normal;
}
}
@media (max-width: 600px) {
.provider-models-panel {
gap: 14px;
}
.provider-model-row {
align-items: stretch;
flex-direction: column;
gap: 10px;
padding: 14px 0;
}
.provider-model-row__actions {
align-self: flex-end;
flex-wrap: wrap;
justify-content: flex-end;
}
}
</style>

View File

@@ -1,125 +1,150 @@
<template>
<v-card class="provider-sources-panel h-100" elevation="0">
<div class="provider-sources-panel">
<div class="provider-sources-head">
<div class="provider-sources-title-wrap">
<div class="provider-sources-title-row">
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
<v-chip size="x-small" variant="tonal" label>
{{ displayedProviderSources.length }}
</v-chip>
</div>
<div class="provider-sources-head__copy">
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
</div>
<StyledMenu>
<template #activator="{ props }">
<v-btn
v-bind="props"
prepend-icon="mdi-plus"
color="primary"
variant="tonal"
size="small"
>
{{ tm('providerSources.add') }}
</v-btn>
</template>
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
class="styled-menu-item"
@click="emitAddSource(sourceType.value)"
>
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="sourceType.icon" :src="sourceType.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<div v-if="isMobile && displayedProviderSources.length > 0" class="provider-sources-mobile">
<div class="d-flex align-center ga-2">
<v-select
:model-value="selectedId"
:items="mobileSourceItems"
item-title="label"
item-value="value"
:label="tm('providerSources.selectCreated')"
variant="solo-filled"
density="comfortable"
flat
hide-details
class="mobile-source-select"
@update:model-value="onMobileSourceChange"
<div class="provider-sources-controls">
<div class="provider-sources-mobile-select">
<v-select
:model-value="selectedSourceValue"
:items="sourceOptions"
item-title="title"
item-value="value"
density="compact"
variant="solo-filled"
flat
hide-details
:placeholder="tm('providerSources.selectHint')"
@update:model-value="selectSourceByValue"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="item.raw.icon" :src="item.raw.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
<template #selection="{ item }">
<div class="provider-source-select-value">
<v-avatar size="22" rounded="lg" class="provider-source-avatar">
<v-img
v-if="item.raw.source?.provider"
:src="resolveSourceIcon(item.raw.source)"
alt="provider logo"
cover
></v-img>
<v-icon v-else size="14">mdi-creation</v-icon>
</v-avatar>
</template>
</v-list-item>
<span>{{ item.raw.title }}</span>
</div>
</template>
<template #item="{ props: itemProps, item }">
<v-list-item
v-bind="itemProps"
:subtitle="item.raw.subtitle"
>
<template #prepend>
<v-avatar size="24" rounded="lg" class="provider-source-avatar me-2">
<v-img
v-if="item.raw.source?.provider"
:src="resolveSourceIcon(item.raw.source)"
alt="provider logo"
cover
></v-img>
<v-icon v-else size="14">mdi-creation</v-icon>
</v-avatar>
</template>
</v-list-item>
</template>
</v-select>
</div>
<StyledMenu>
<template #activator="{ props }">
<v-btn
v-bind="props"
prepend-icon="mdi-plus"
color="primary"
variant="text"
size="small"
rounded="xl"
>
{{ tm('providerSources.add') }}
</v-btn>
</template>
</v-select>
<v-btn
v-if="selectedProviderSource"
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click.stop="emitDeleteSource(selectedProviderSource)"
></v-btn>
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
class="styled-menu-item"
@click="emitAddSource(sourceType.value)"
>
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2 provider-source-avatar">
<v-img
v-if="sourceType.icon"
:src="sourceType.icon"
alt="provider icon"
cover
></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
</div>
<div v-else-if="displayedProviderSources.length > 0" class="provider-sources-list-wrap">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
:value="source.id"
:active="isActive(source)"
:class="['provider-source-list-item', { 'provider-source-list-item--active': isActive(source) }]"
rounded="lg"
@click="emitSelectSource(source)"
>
<template #prepend>
<v-avatar size="28" class="provider-source-avatar" rounded="0">
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
<v-icon v-else size="20">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="provider-source-title">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="provider-source-subtitle text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
<v-btn
v-if="!source.isPlaceholder"
icon="mdi-delete"
variant="text"
size="x-small"
color="error"
:ripple="false"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
</template>
</v-list-item>
</v-list>
<div v-if="displayedProviderSources.length > 0" class="provider-sources-list">
<button
v-for="source in displayedProviderSources"
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
type="button"
:class="[
'provider-source-item',
{
'provider-source-item--active': isActive(source)
}
]"
@click="emitSelectSource(source)"
>
<v-avatar size="28" rounded="lg" class="provider-source-item__avatar provider-source-avatar">
<v-img
v-if="source?.provider"
:src="resolveSourceIcon(source)"
alt="provider logo"
cover
></v-img>
<v-icon v-else size="16">mdi-creation</v-icon>
</v-avatar>
<div class="provider-source-item__content">
<div class="provider-source-item__title">
{{ getSourceDisplayName(source) }}
</div>
<div class="provider-source-item__subtitle">
{{ source.api_base || sourceBadge(source) }}
</div>
</div>
<div class="provider-source-item__actions">
<v-btn
v-if="!source.isPlaceholder"
icon="mdi-delete-outline"
variant="text"
size="small"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
</button>
</div>
<div v-else class="text-center py-8 px-4">
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
<div v-else class="provider-sources-empty">
<v-icon size="44" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="provider-sources-empty__text">{{ tm('providerSources.empty') }}</p>
</div>
</v-card>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import StyledMenu from '@/components/shared/StyledMenu.vue'
const props = defineProps({
@@ -155,144 +180,217 @@ const emit = defineEmits([
'delete-provider-source'
])
const { smAndDown } = useDisplay()
const selectedId = computed(() => props.selectedProviderSource?.id || null)
const isMobile = computed(() => smAndDown.value)
const mobileSourceItems = computed(() =>
(props.displayedProviderSources || []).map((source) => ({
value: source.id,
label: props.getSourceDisplayName(source),
icon: props.resolveSourceIcon(source),
source
}))
)
const isActive = (source) => {
if (source.isPlaceholder) return false
return selectedId.value !== null && selectedId.value === source.id
}
const onMobileSourceChange = (sourceId) => {
const matched = mobileSourceItems.value.find((item) => item.value === sourceId)
if (matched?.source) {
emitSelectSource(matched.source)
}
}
const sourceBadge = (source) => source.provider || source.templateKey || 'source'
const sourceValue = (source) => (
source.isPlaceholder ? `template:${source.templateKey}` : `source:${source.id}`
)
const sourceOptions = computed(() =>
props.displayedProviderSources.map((source) => ({
title: props.getSourceDisplayName(source),
subtitle: source.api_base || sourceBadge(source),
value: sourceValue(source),
source
}))
)
const selectedSourceValue = computed(() => {
if (!props.selectedProviderSource) return null
return sourceValue(props.selectedProviderSource)
})
const emitAddSource = (type) => emit('add-provider-source', type)
const emitSelectSource = (source) => emit('select-provider-source', source)
const emitDeleteSource = (source) => emit('delete-provider-source', source)
const selectSourceByValue = (value) => {
const option = sourceOptions.value.find((item) => item.value === value)
if (option?.source) {
emitSelectSource(option.source)
}
}
</script>
<style scoped>
.provider-sources-panel {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 16px;
background: rgb(var(--v-theme-surface));
min-height: 320px;
overflow: hidden;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.provider-sources-head {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
gap: 12px;
padding: 20px 20px 12px;
}
.provider-sources-title-row {
.provider-sources-head__copy {
min-width: 0;
}
.provider-sources-controls {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
min-width: 0;
}
.provider-sources-title {
margin: 0;
font-size: 17px;
line-height: 1.2;
font-size: 16px;
font-weight: 650;
line-height: 1.3;
}
.provider-sources-mobile {
padding: 16px;
padding: 8px 20px 16px;
}
.provider-sources-list-wrap {
padding: 8px 8px 10px;
.provider-sources-mobile-select {
display: none;
min-width: 0;
flex: 1;
}
.provider-source-list {
.provider-source-select-value {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
}
.provider-source-select-value span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-sources-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0;
background: transparent;
padding: 6px 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.provider-source-list-item {
margin-bottom: 2px;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
.provider-source-item {
width: 100%;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
text-align: left;
}
.provider-source-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.06);
border: 1px solid transparent;
.provider-source-item:hover,
.provider-source-item--active {
background: rgba(var(--v-theme-on-surface), 0.05);
}
.provider-source-avatar {
background: transparent !important;
}
.provider-source-title {
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.provider-source-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 12px;
line-height: 1.5;
}
.provider-source-list :deep(.v-list-item__prepend) {
margin-inline-end: 10px;
}
.provider-source-list :deep(.v-list-item__content) {
.provider-source-item__content {
min-width: 0;
flex: 1;
}
.provider-source-list :deep(.v-list-item__append) {
.provider-source-item__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 600;
}
.provider-source-item__subtitle {
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.54);
font-size: 12px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-source-item__actions {
opacity: 0;
transition: opacity 0.15s ease;
}
.provider-source-list-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.025);
}
.provider-source-list-item:hover :deep(.v-list-item__append),
.provider-source-list-item--active :deep(.v-list-item__append) {
.provider-source-item:hover .provider-source-item__actions,
.provider-source-item--active .provider-source-item__actions {
opacity: 1;
}
.provider-sources-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
text-align: center;
}
.provider-sources-empty__text {
margin: 0;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 13px;
}
@media (max-width: 960px) {
.provider-source-list {
max-height: none;
}
.provider-sources-panel {
min-height: auto;
height: auto;
}
.provider-sources-head {
padding: 16px 16px 8px;
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.provider-sources-mobile-select {
display: block;
}
.provider-sources-controls {
width: 100%;
}
.provider-sources-list {
display: none;
}
.provider-sources-empty {
min-height: 160px;
}
}
@media (max-width: 600px) {
.provider-sources-controls :deep(.v-btn) {
min-width: max-content;
}
}
</style>
<style>
.v-theme--PurpleThemeDark .provider-source-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.1);
border: 1px solid transparent;
}
</style>

View File

@@ -168,7 +168,10 @@
</v-btn>
</div>
<div class="provider-drawer-content">
<ProviderPage :default-tab="defaultTab" />
<ProviderChatCompletionPanel
v-if="defaultTab === 'chat_completion'"
/>
<ProviderPage v-else :default-tab="defaultTab" />
</div>
</v-card>
</v-overlay>
@@ -178,6 +181,7 @@
import { computed, ref, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({
@@ -426,4 +430,49 @@ function closeProviderDrawer() {
height: 100%;
overflow: auto;
}
@media (max-width: 960px) {
.provider-drawer-card {
width: calc(100dvw - 24px);
height: calc(100dvh - 24px);
margin: 12px;
}
}
@media (max-width: 600px) {
.provider-name-text {
max-width: 100%;
}
.provider-drawer-overlay {
align-items: stretch;
justify-content: stretch;
}
.provider-drawer-card {
width: 100dvw;
height: 100dvh;
margin: 0;
border-radius: 0;
}
.provider-drawer-header {
padding: 8px 12px;
}
.provider-drawer-content {
overflow: auto;
}
:deep(.v-overlay__content) {
width: 100dvw;
max-width: 100dvw;
margin: 0;
}
:deep(.v-dialog > .v-overlay__content) {
width: calc(100dvw - 24px);
max-width: calc(100dvw - 24px);
}
}
</style>

View File

@@ -51,7 +51,7 @@
"fullscreen": "Fullscreen Mode",
"exitFullscreen": "Exit Fullscreen",
"reply": "Reply",
"providerConfig": "AI Configuration",
"providerConfig": "Model Configuration",
"toolsUsed": "Tool Used",
"toolCallUsed": "Used {name} tool",
"pythonCodeAnalysis": "Python Code Analysis Used"

View File

@@ -123,6 +123,7 @@
}
},
"models": {
"title": "Models",
"available": "Available Models",
"configured": "Configured Models",
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",

View File

@@ -124,6 +124,7 @@
}
},
"models": {
"title": "Модели",
"available": "Доступные модели",
"configured": "Настроенные модели",
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",

View File

@@ -51,7 +51,7 @@
"fullscreen": "全屏模式",
"exitFullscreen": "退出全屏",
"reply": "引用回复",
"providerConfig": "AI 配置",
"providerConfig": "模型配置",
"toolsUsed": "已使用工具",
"toolCallUsed": "已使用 {name} 工具",
"pythonCodeAnalysis": "已使用 Python 代码分析"

View File

@@ -124,6 +124,7 @@
}
},
"models": {
"title": "模型",
"available": "可用模型",
"configured": "已配置的模型",
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { RouterView, useRoute } from 'vue-router';
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import axios from 'axios';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
@@ -18,13 +18,19 @@ const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
const shouldMountChat = ref(isCurrentChatRoute.value);
const showSidebar = computed(() => !isCurrentChatRoute.value)
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
const showFirstNoticeDialog = ref(false);
watch(isCurrentChatRoute, (isChatRoute) => {
if (isChatRoute) {
shouldMountChat.value = true;
}
});
const checkMigration = async (): Promise<boolean> => {
try {
const response = await axios.get('/api/stat/version');
@@ -116,10 +122,14 @@ onMounted(() => {
minHeight: isCurrentChatRoute ? 'unset' : undefined
}">
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
<Chat />
<div
v-if="shouldMountChat"
v-show="isCurrentChatRoute"
style="height: 100%; width: 100%; overflow: hidden;"
>
<Chat :active="isCurrentChatRoute" />
</div>
<RouterView v-else />
<RouterView v-if="!isCurrentChatRoute" />
</div>
</v-container>
</v-main>

View File

@@ -19,7 +19,7 @@ $code-text-color: #111827 !default;
--astrbot-code-color: #{$code-text-color};
}
$body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default;
$body-font-family: $cjk-sans-fallback, sans-serif !default;
$heading-font-family: $body-font-family !default;
$btn-font-weight: 400 !default;
$btn-letter-spacing: 0 !default;

View File

@@ -79,14 +79,6 @@ $sizes: (
// font family
body {
.Poppins {
font-family: 'Poppins', $cjk-sans-fallback, sans-serif !important;
}
.Inter {
font-family: 'Inter', $cjk-sans-fallback, sans-serif !important;
}
.Outfit {
font-family: 'Outfit', $cjk-sans-fallback, sans-serif !important;
}

View File

@@ -6,7 +6,7 @@ export const useCustomizerStore = defineStore("customizer", {
Sidebar_drawer: config.Sidebar_drawer,
Customizer_drawer: config.Customizer_drawer,
mini_sidebar: config.mini_sidebar,
fontTheme: "Poppins",
fontTheme: "Noto Sans SC",
uiTheme: config.uiTheme,
inputBg: config.inputBg,
chatSidebarOpen: false // chat mode mobile sidebar state

View File

@@ -1,7 +1,6 @@
<template>
<div class="provider-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
@@ -12,121 +11,139 @@
</p>
</div>
<div v-if="selectedProviderType !== 'chat_completion'">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true"
rounded="xl" size="x-large">
<v-btn
color="primary"
prepend-icon="mdi-plus"
variant="tonal"
rounded="xl"
size="x-large"
@click="showAddProviderDialog = true"
>
{{ tm('providers.addProvider') }}
</v-btn>
</div>
</v-row>
<div>
<!-- Provider Type 标签页 -->
<v-tabs v-model="selectedProviderType" bg-color="transparent" class="mb-4">
<v-tab v-for="type in providerTypes" :key="type.value" :value="type.value" class="font-weight-medium px-3">
<v-tab
v-for="type in providerTypes"
:key="type.value"
:value="type.value"
class="font-weight-medium px-3"
>
<v-icon start>{{ type.icon }}</v-icon>
{{ type.label }}
</v-tab>
</v-tabs>
<!-- Chat Completion: 左侧列表 + 右侧上下卡片布局 -->
<div v-if="selectedProviderType === 'chat_completion'" class="provider-workbench">
<v-row class="provider-workbench__shell">
<v-col cols="12" md="4" lg="3" class="provider-workbench__sources">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
:available-source-types="availableSourceTypes"
:tm="tm"
:resolve-source-icon="resolveSourceIcon"
:get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource"
@select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource"
/>
</v-col>
<div class="provider-workbench__sidebar">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
:available-source-types="availableSourceTypes"
:tm="tm"
:resolve-source-icon="resolveSourceIcon"
:get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource"
@select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource"
/>
</div>
<v-col cols="12" md="8" lg="9" class="provider-workbench__settings">
<v-card class="provider-config-card provider-settings-panel h-100" elevation="0">
<div v-if="selectedProviderSource" class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-kicker">{{ tm('providers.settings') }}</div>
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<div class="provider-workbench__divider"></div>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
@click="saveProviderSource"
variant="tonal"
>
{{ tm('providerSources.save') }}
</v-btn>
<div class="provider-workbench__main">
<div v-if="selectedProviderSource" class="provider-config-shell">
<div class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<v-card-text class="provider-config-body">
<template v-if="selectedProviderSource">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema"
metadataKey="provider" :is-editing="true" />
</section>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
variant="tonal"
rounded="xl"
@click="saveProviderSource"
>
{{ tm('providerSources.save') }}
</v-btn>
</div>
</div>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider></v-divider>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
</template>
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
<div class="provider-config-body">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<AstrBotConfig
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider v-if="advancedSourceConfig"></v-divider>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider></v-divider>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
</div>
</div>
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</div>
<!-- 其他类型: 卡片布局 -->
<template v-else>
<v-row v-if="filteredProviders.length === 0">
<v-col cols="12" class="text-center pa-8">
@@ -136,20 +153,30 @@
</v-row>
<v-row v-else>
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card :item="provider" title-field="id" enabled-field="enable"
:loading="isProviderTesting(provider.id)" @toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true">
<item-card
:item="provider"
title-field="id"
enabled-field="enable"
:loading="isProviderTesting(provider.id)"
:bglogo="getProviderIcon(provider.provider)"
:show-copy-button="true"
@toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
@delete="deleteProvider"
@edit="configExistingProvider"
@copy="copyProvider"
>
<template #item-details="{ item }">
<!-- 测试状态 chip -->
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
<template v-slot:activator="{ props }">
<template #activator="{ props }">
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
<v-icon start size="small">
{{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' :
getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' :
'mdi-clock-outline' }}
{{
getProviderStatus(item.id).status === 'available'
? 'mdi-check-circle'
: getProviderStatus(item.id).status === 'unavailable'
? 'mdi-alert-circle'
: 'mdi-clock-outline'
}}
</v-icon>
{{ getStatusText(getProviderStatus(item.id).status) }}
</v-chip>
@@ -160,9 +187,17 @@
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
</v-tooltip>
</template>
<template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
<v-btn
style="z-index: 100000;"
variant="tonal"
color="info"
rounded="xl"
size="small"
:loading="isProviderTesting(item.id)"
@click="testSingleProvider(item)"
>
{{ tm('availability.test') }}
</v-btn>
</template>
@@ -173,18 +208,32 @@
</div>
</v-container>
<!-- 添加提供商对话框 -->
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="configSchema"
<AddNewProvider
v-model:show="showAddProviderDialog"
:metadata="configSchema"
:current-provider-type="selectedProviderType"
@select-template="selectProviderTemplate" />
@select-template="selectProviderTemplate"
/>
<!-- 手动添加模型对话框 -->
<v-dialog v-model="showManualModelDialog" max-width="400">
<v-card :title="tm('models.manualDialogTitle')">
<v-card-text class="py-4">
<v-text-field v-model="manualModelId" :label="tm('models.manualDialogModelLabel')" flat variant="solo-filled" autofocus clearable></v-text-field>
<v-text-field :model-value="manualProviderId" flat variant="solo-filled" :label="tm('models.manualDialogPreviewLabel')" persistent-hint
:hint="tm('models.manualDialogPreviewHint')"></v-text-field>
<v-text-field
v-model="manualModelId"
:label="tm('models.manualDialogModelLabel')"
flat
variant="solo-filled"
autofocus
clearable
></v-text-field>
<v-text-field
:model-value="manualProviderId"
flat
variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')"
persistent-hint
:hint="tm('models.manualDialogPreviewHint')"
></v-text-field>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
@@ -194,56 +243,69 @@
</v-card>
</v-dialog>
<!-- 配置对话框 -->
<v-dialog v-model="showProviderCfg" width="900" persistent>
<v-card
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')"
>
<v-card-text class="py-4">
<AstrBotConfig :iterable="newSelectedProviderConfig" :metadata="configSchema"
metadataKey="provider" :is-editing="updatingMode" />
<AstrBotConfig
:iterable="newSelectedProviderConfig"
:metadata="configSchema"
metadataKey="provider"
:is-editing="updatingMode"
/>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
<v-btn variant="text" :disabled="loading" @click="showProviderCfg = false">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" @click="newProvider" :loading="loading">
<v-btn color="primary" :loading="loading" @click="newProvider">
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 已配置模型编辑对话框 -->
<v-dialog v-model="showProviderEditDialog" width="800">
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效旧版本 AstrBot 提供商 ID 是下方的 ID</small>
<AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
<AstrBotConfig
v-if="providerEditData"
:iterable="providerEditData"
:metadata="configSchema"
metadataKey="provider"
:is-editing="true"
/>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderEditDialog = false"
:disabled="savingProviders.includes(providerEditData?.id)">
<v-btn
variant="text"
:disabled="savingProviders.includes(providerEditData?.id)"
@click="showProviderEditDialog = false"
>
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)">
<v-btn
color="primary"
:loading="savingProviders.includes(providerEditData?.id)"
@click="saveEditedProvider"
>
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="top">
{{ snackbar.message }}
</v-snackbar>
<!-- Agent Runner 测试提示对话框 -->
<v-dialog v-model="showAgentRunnerDialog" max-width="520" persistent>
<v-card>
<v-card-title class="text-h3 d-flex align-center">
@@ -340,14 +402,13 @@ const {
deleteProvider,
modelAlreadyConfigured,
testProvider,
loadConfig,
loadConfig
} = useProviderSources({
defaultTab: props.defaultTab,
tm,
showMessage
})
// 非 chat 类型的状态
const showAddProviderDialog = ref(false)
const showProviderCfg = ref(false)
const newSelectedProviderName = ref('')
@@ -361,7 +422,6 @@ const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const showManualModelDialog = ref(false)
const savingProviders = ref([])
function openProviderEdit(provider) {
@@ -401,7 +461,6 @@ watch(() => props.defaultTab, (val) => {
updateDefaultTab(val)
})
// ===== 非 chat 类型的方法 =====
function getEmptyText() {
return tm('providers.empty.typed', { type: selectedProviderType.value })
}
@@ -421,7 +480,6 @@ function configExistingProvider(provider) {
newProviderOriginalId.value = provider.id
newSelectedProviderConfig.value = {}
// 比对默认配置模版,看看是否有更新
let templates = configSchema.value.provider.config_template || {}
let defaultConfig = {}
for (let key in templates) {
@@ -484,20 +542,20 @@ async function newProvider() {
config: newSelectedProviderConfig.value
})
if (res.data.status === 'error') {
showMessage(res.data.message || "更新失败!", 'error')
showMessage(res.data.message || '更新失败!', 'error')
return
}
showMessage(res.data.message || "更新成功!")
showMessage(res.data.message || '更新成功!')
if (wasUpdating) {
updatingMode.value = false
}
} else {
const res = await axios.post('/api/config/provider/new', newSelectedProviderConfig.value)
if (res.data.status === 'error') {
showMessage(res.data.message || "添加失败!", 'error')
showMessage(res.data.message || '添加失败!', 'error')
return
}
showMessage(res.data.message || "添加成功!")
showMessage(res.data.message || '添加成功!')
}
showProviderCfg.value = false
} catch (err) {
@@ -674,47 +732,43 @@ function goToConfigPage() {
router.push('/config')
showAgentRunnerDialog.value = false
}
</script>
<style scoped>
.provider-page {
--provider-surface: rgb(var(--v-theme-surface));
--provider-text: rgb(var(--v-theme-on-surface));
--provider-muted: rgba(var(--v-theme-on-surface), 0.68);
--provider-subtle: rgba(var(--v-theme-on-surface), 0.56);
--provider-border: rgba(var(--v-theme-on-surface), 0.1);
--provider-border-strong: rgba(var(--v-theme-on-surface), 0.14);
--provider-soft: rgba(var(--v-theme-primary), 0.08);
--provider-border: rgba(var(--v-theme-on-surface), 0.08);
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.provider-workbench {
display: flex;
justify-content: center;
}
.provider-workbench__shell {
width: 100%;
max-width: 1500px;
}
.provider-workbench__sources,
.provider-workbench__settings {
min-width: 0;
}
.provider-config-card {
min-height: 280px;
border: 1px solid var(--provider-border);
border-radius: 16px;
border-radius: 24px;
background: var(--provider-surface);
display: grid;
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
min-height: 760px;
overflow: hidden;
}
.provider-settings-panel {
.provider-workbench__sidebar,
.provider-workbench__main {
min-width: 0;
}
.provider-workbench__divider {
background: var(--provider-border);
}
.provider-workbench__main {
display: flex;
}
.provider-config-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
@@ -723,63 +777,46 @@ function goToConfigPage() {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 20px 20px 16px;
border-bottom: 1px solid var(--provider-border);
gap: 16px;
padding: 18px 22px 14px;
}
.provider-config-headline {
min-width: 0;
}
.provider-config-kicker {
color: var(--provider-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.provider-config-title {
margin-top: 8px;
font-size: 22px;
font-size: 21px;
line-height: 1.1;
font-weight: 650;
font-weight: 680;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
color: var(--provider-text);
}
.provider-config-subtitle {
margin-top: 8px;
color: var(--provider-muted);
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.provider-config-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.provider-config-body {
display: grid;
gap: 14px;
padding: 18px 20px 20px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.provider-section {
border: 1px solid var(--provider-border);
border-radius: 14px;
background: rgba(var(--v-theme-primary), 0.02);
padding: 16px;
padding: 18px 22px;
}
.provider-section--models {
padding: 18px;
padding-top: 16px;
}
.provider-section-head {
@@ -790,30 +827,92 @@ function goToConfigPage() {
font-size: 16px;
font-weight: 650;
line-height: 1.4;
color: var(--provider-text);
}
.provider-empty-state {
flex: 1;
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--provider-muted);
color: rgba(var(--v-theme-on-surface), 0.56);
}
@media (max-width: 960px) {
.provider-config-card {
.provider-page {
padding: 12px;
padding-bottom: 32px;
}
.provider-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto 1px auto;
min-height: auto;
}
.provider-workbench__divider {
height: 1px;
}
.provider-config-header {
flex-direction: column;
align-items: flex-start;
align-items: stretch;
padding: 16px;
}
.provider-config-actions :deep(.v-btn) {
width: 100%;
}
.provider-section {
padding: 16px;
}
}
@media (max-width: 600px) {
.provider-page {
padding: 8px;
padding-bottom: 24px;
}
.provider-page :deep(.v-container) > .v-row:first-child {
margin: 0;
padding: 8px 4px 16px !important;
}
.provider-page :deep(.v-container) > .v-row:first-child > div {
width: 100%;
}
.provider-page :deep(.v-container) > .v-row:first-child .v-btn {
width: 100%;
}
.provider-page :deep(.v-tabs) {
overflow-x: auto;
}
.provider-workbench {
border-radius: 16px;
overflow: visible;
}
.provider-workbench__main {
overflow: visible;
}
.provider-config-body {
padding: 18px;
overflow-y: visible;
}
.provider-config-title {
font-size: 18px;
}
.provider-empty-state {
min-height: 260px;
padding: 24px;
}
}
</style>