mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-03 03:00:15 +08:00
Compare commits
2 Commits
codex/rest
...
perf/provi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494771799a | ||
|
|
713b2350d2 |
@@ -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)
|
||||
|
||||
# 将消息放入会话特定的队列
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"available": "Available Models",
|
||||
"configured": "Configured Models",
|
||||
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "Модели",
|
||||
"available": "Доступные модели",
|
||||
"configured": "Настроенные модели",
|
||||
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"fullscreen": "全屏模式",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"reply": "引用回复",
|
||||
"providerConfig": "AI 配置",
|
||||
"providerConfig": "模型配置",
|
||||
"toolsUsed": "已使用工具",
|
||||
"toolCallUsed": "已使用 {name} 工具",
|
||||
"pythonCodeAnalysis": "已使用 Python 代码分析"
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "模型",
|
||||
"available": "可用模型",
|
||||
"configured": "已配置的模型",
|
||||
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user