fix: 修复 Vue 样式穿透警告并更新 tsconfig 解析模式

This commit is contained in:
LIghtJUNction
2026-04-02 23:16:09 +08:00
parent d9e91bb41e
commit 2126002b7a
36 changed files with 4846 additions and 3020 deletions
+14 -11
View File
@@ -25,7 +25,7 @@ const MONACO_DOMPURIFY = join(
"base",
"browser",
"dompurify",
"dompurify.js"
"dompurify.js",
);
// Files inside monaco-editor dev/min bundles that embed dompurify source
@@ -35,7 +35,8 @@ const BUNDLED_FILES = [
];
const PATCHED_VERSION = "3.3.3";
const PATCHED_URL = "https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.js";
const PATCHED_URL =
"https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.js";
const dompurifyVersion = (content) => {
const m = content.match(/DOMPurify\s+(\d+\.\d+\.\d+)/);
@@ -73,24 +74,26 @@ async function patchFile(source, target) {
if (currentVersion && !isVulnerable(currentVersion)) {
console.log(
`[patch-dompurify] ${target} already at safe version ${currentVersion}, skipping.`
`[patch-dompurify] ${target} already at safe version ${currentVersion}, skipping.`,
);
return;
}
console.log(`[patch-dompurify] Downloading patched DOMPurify ${PATCHED_VERSION}...`);
console.log(
`[patch-dompurify] Downloading patched DOMPurify ${PATCHED_VERSION}...`,
);
const patched = await download(source);
const patchedVer = dompurifyVersion(patched);
if (!patchedVer || isVulnerable(patchedVer)) {
throw new Error(
`Downloaded DOMPurify version is still vulnerable: got ${patchedVer}`
`Downloaded DOMPurify version is still vulnerable: got ${patchedVer}`,
);
}
writeFileSync(target, patched, "utf8");
console.log(
`[patch-dompurify] Patched ${target} (${currentVersion || "new"} -> ${PATCHED_VERSION})`
`[patch-dompurify] Patched ${target} (${currentVersion || "new"} -> ${PATCHED_VERSION})`,
);
}
@@ -103,27 +106,27 @@ async function patchBundledContent(target) {
if (!currentVersion || !isVulnerable(currentVersion)) return;
console.log(
`[patch-dompurify] Downloading patched DOMPurify ${PATCHED_VERSION} for bundling...`
`[patch-dompurify] Downloading patched DOMPurify ${PATCHED_VERSION} for bundling...`,
);
const patched = await download(PATCHED_URL);
// Replace the dompurify version header + IIFE in the bundled file
const patchedHeader = patched.match(
/\/\*! @license DOMPurify [\d.]+ \|[\s\S]*?Mozilla Public License 2.0 \*\/[\s\S]*?^\(function\(\)/m
/\/\*! @license DOMPurify [\d.]+ \|[\s\S]*?Mozilla Public License 2.0 \*\/[\s\S]*?^\(function\(\)/m,
)?.[0];
if (patchedHeader) {
const newContent = content.replace(
/\/\*! @license DOMPurify 3\.2\.7[\s\S]*?^\(function\(\)/m,
patchedHeader
patchedHeader,
);
writeFileSync(target, newContent, "utf8");
console.log(
`[patch-dompurify] Patched bundled dompurify in ${target} (${currentVersion} -> ${PATCHED_VERSION})`
`[patch-dompurify] Patched bundled dompurify in ${target} (${currentVersion} -> ${PATCHED_VERSION})`,
);
} else {
console.warn(
`[patch-dompurify] Could not find dompurify IIFE pattern in ${target}, skipping.`
`[patch-dompurify] Could not find dompurify IIFE pattern in ${target}, skipping.`,
);
}
}
+17 -11
View File
@@ -1,5 +1,10 @@
<template>
<div class="bg" ref="bgEl" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<div
class="bg"
ref="bgEl"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
>
<canvas ref="canvasEl" class="bg-canvas"></canvas>
<div class="gravity-well"></div>
</div>
@@ -61,7 +66,9 @@ function draw() {
const my = smoothY.value;
// draw static base grid (very faint)
ctx.strokeStyle = dark ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 49, 83, 0.03)";
ctx.strokeStyle = dark
? "rgba(255, 255, 255, 0.03)"
: "rgba(0, 49, 83, 0.03)";
ctx.lineWidth = 0.5;
for (let x = 0; x <= W; x += GRID) {
ctx.beginPath();
@@ -101,9 +108,7 @@ function draw() {
if (dist < ENERGY_RADIUS) {
const t = 1 - dist / ENERGY_RADIUS;
const eased = t * t * (3 - 2 * t);
crossAlpha = dark
? 0.05 + eased * 0.35
: 0.04 + eased * 0.4;
crossAlpha = dark ? 0.05 + eased * 0.35 : 0.04 + eased * 0.4;
crossScale = 1.0 - eased * SINK_DEPTH;
crossBlue = dark ? Math.floor(eased * 180) : 0;
}
@@ -111,11 +116,12 @@ function draw() {
if (crossAlpha < 0.01) continue;
const halfLen = CROSS_SIZE * crossScale;
const strokeColor = crossBlue > 0
? `rgba(${crossBlue * 0.3}, ${crossBlue * 0.8}, ${crossBlue}, ${crossAlpha})`
: dark
? `rgba(255, 255, 255, ${crossAlpha})`
: `rgba(26, 46, 60, ${crossAlpha})`;
const strokeColor =
crossBlue > 0
? `rgba(${crossBlue * 0.3}, ${crossBlue * 0.8}, ${crossBlue}, ${crossAlpha})`
: dark
? `rgba(255, 255, 255, ${crossAlpha})`
: `rgba(26, 46, 60, ${crossAlpha})`;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 0.4;
@@ -191,7 +197,7 @@ onUnmounted(() => {
/* Light mode: clean white */
.v-theme--bluebusinesstheme .bg {
background: #FFFFFF;
background: #ffffff;
}
.bg-canvas {
@@ -1144,43 +1144,43 @@ export default {
</script>
<style scoped>
::v-deep(.hr-node) {
:deep(.hr-node) {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
opacity: 0.5;
border-top-width: 0.3px;
}
::v-deep(.paragraph-node) {
:deep(.paragraph-node) {
margin: 0.5rem 0;
line-height: 1.7;
margin-block: 1rem;
}
::v-deep(.list-node) {
:deep(.list-node) {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
::v-deep(.mermaid-block-header) {
:deep(.mermaid-block-header) {
gap: 8px;
}
::v-deep(code.bg-secondary) {
:deep(code.bg-secondary) {
background-color: #ececec !important;
color: #0d0d0d !important;
}
::v-deep(code.rounded) {
:deep(code.rounded) {
border-radius: 6px !important;
}
.messages-container.is-dark ::v-deep(code.bg-secondary) {
.messages-container.is-dark :deep(code.bg-secondary) {
background-color: #424242 !important;
color: #ffffff !important;
}
.messages-container.is-dark ::v-deep(.code-block-container) {
.messages-container.is-dark :deep(.code-block-container) {
background-color: #1f1f1f !important;
}
@@ -107,7 +107,8 @@
<div
v-else-if="
renderPart.type === 'part' &&
renderPart.part?.type === 'image' && renderPart.part?.embedded_url
renderPart.part?.type === 'image' &&
renderPart.part?.embedded_url
"
class="embedded-images"
>
@@ -124,7 +125,8 @@
<div
v-else-if="
renderPart.type === 'part' &&
renderPart.part?.type === 'record' && renderPart.part?.embedded_url
renderPart.part?.type === 'record' &&
renderPart.part?.embedded_url
"
class="embedded-audio"
>
@@ -138,7 +140,8 @@
<div
v-else-if="
renderPart.type === 'part' &&
renderPart.part?.type === 'file' && renderPart.part?.embedded_file
renderPart.part?.type === 'file' &&
renderPart.part?.embedded_file
"
class="embedded-files"
>
@@ -195,7 +198,9 @@
}}</span>
<v-icon
v-if="
downloadingFiles?.has(renderPart.part?.embedded_file?.attachment_id)
downloadingFiles?.has(
renderPart.part?.embedded_file?.attachment_id,
)
"
size="small"
class="download-icon"
@@ -1,39 +1,39 @@
<script setup>
import { computed } from 'vue';
import { computed } from "vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
const props = defineProps({
plugin: {
type: Object,
required: true
required: true,
},
isPinned: {
type: Boolean,
default: false
default: false,
},
tm: {
type: Function,
required: true
required: true,
},
dragged: {
type: Boolean,
default: false
}
default: false,
},
});
const emit = defineEmits([
'toggle-pin',
'view-readme',
'open-config',
'reload',
'update',
'show-info',
'uninstall',
'dragstart',
'dragover',
'dragenter',
'dragend',
'drop'
"toggle-pin",
"view-readme",
"open-config",
"reload",
"update",
"show-info",
"uninstall",
"dragstart",
"dragover",
"dragenter",
"dragend",
"drop",
]);
const handlePinnedImgError = (e) => {
@@ -42,12 +42,14 @@ const handlePinnedImgError = (e) => {
const authorDisplay = computed(() => {
const p = props.plugin || {};
if (typeof p.author === 'string' && p.author.trim()) return p.author;
if (Array.isArray(p.authors) && p.authors.length) return p.authors.join(', ');
if (typeof p.author_name === 'string' && p.author_name.trim()) return p.author_name;
if (typeof p.owner === 'string' && p.owner.trim()) return p.owner;
if (p.author && typeof p.author === 'object' && p.author.name) return p.author.name;
return '';
if (typeof p.author === "string" && p.author.trim()) return p.author;
if (Array.isArray(p.authors) && p.authors.length) return p.authors.join(", ");
if (typeof p.author_name === "string" && p.author_name.trim())
return p.author_name;
if (typeof p.owner === "string" && p.owner.trim()) return p.owner;
if (p.author && typeof p.author === "object" && p.author.name)
return p.author.name;
return "";
});
</script>
@@ -55,7 +57,7 @@ const authorDisplay = computed(() => {
<div
class="pinned-item pinned-card-wrapper"
:class="{ 'is-dragging': dragged }"
style="position:relative;"
style="position: relative"
draggable="true"
@dragstart="$emit('dragstart')"
@dragover.prevent="$emit('dragover', $event)"
@@ -65,7 +67,10 @@ const authorDisplay = computed(() => {
>
<v-menu offset-y>
<template #activator="{ props: menuProps }">
<div class="d-flex flex-column align-center" style="cursor: pointer; width: 80px;">
<div
class="d-flex flex-column align-center"
style="cursor: pointer; width: 80px"
>
<v-avatar
v-bind="menuProps"
size="72"
@@ -73,14 +78,23 @@ const authorDisplay = computed(() => {
:title="plugin.display_name || plugin.name"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:src="
typeof plugin.logo === 'string' && plugin.logo.trim()
? plugin.logo
: defaultPluginIcon
"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<span
class="text-caption text-center text-truncate"
style="width: 100%; font-size: 0.75rem; opacity: 0.9; line-height: 1.2;"
<span
class="text-caption text-center text-truncate"
style="
width: 100%;
font-size: 0.75rem;
opacity: 0.9;
line-height: 1.2;
"
>
{{ plugin.display_name || plugin.name }}
</span>
@@ -88,26 +102,67 @@ const authorDisplay = computed(() => {
</template>
<v-card>
<v-card-title class="d-flex" style="gap:8px; padding:12px; align-items:center;">
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
<v-avatar size="40" class="pinned-avatar" style="width:40px; height:40px;">
<v-card-title
class="d-flex"
style="gap: 8px; padding: 12px; align-items: center"
>
<div
style="display: flex; align-items: center; gap: 8px; min-width: 0"
>
<v-avatar
size="40"
class="pinned-avatar"
style="width: 40px; height: 40px"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:src="
typeof plugin.logo === 'string' && plugin.logo.trim()
? plugin.logo
: defaultPluginIcon
"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<div style="min-width:0; overflow:hidden;">
<div style="font-weight:600; font-size:0.95rem; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ plugin.display_name || plugin.name }}</div>
<div style="font-size:0.8rem; color:var(--v-theme-on-surface); opacity:0.8; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ authorDisplay || (plugin.author || '') }}</div>
<div style="min-width: 0; overflow: hidden">
<div
style="
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
"
>
{{ plugin.display_name || plugin.name }}
</div>
<div
style="
font-size: 0.8rem;
color: var(--v-theme-on-surface);
opacity: 0.8;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
"
>
{{ authorDisplay || plugin.author || "" }}
</div>
</div>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="d-flex" style="gap:8px; padding:12px;">
<v-card-text class="d-flex" style="gap: 8px; padding: 12px">
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="info" @click.stop="$emit('view-readme', plugin)">
<v-btn
v-bind="a"
icon
size="small"
variant="tonal"
color="info"
@click.stop="$emit('view-readme', plugin)"
>
<v-icon>mdi-book-open-page-variant</v-icon>
</v-btn>
</template>
@@ -115,7 +170,14 @@ const authorDisplay = computed(() => {
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('open-config', plugin.name)">
<v-btn
v-bind="a"
icon
size="small"
variant="tonal"
color="primary"
@click.stop="$emit('open-config', plugin.name)"
>
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
@@ -123,7 +185,14 @@ const authorDisplay = computed(() => {
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="primary" @click.stop="$emit('reload', plugin.name)">
<v-btn
v-bind="a"
icon
size="small"
variant="tonal"
color="primary"
@click.stop="$emit('reload', plugin.name)"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</template>
@@ -131,7 +200,14 @@ const authorDisplay = computed(() => {
<v-tooltip location="top" :text="tm('buttons.update')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="warning" @click.stop="$emit('update', plugin.name)">
<v-btn
v-bind="a"
icon
size="small"
variant="tonal"
color="warning"
@click.stop="$emit('update', plugin.name)"
>
<v-icon>mdi-update</v-icon>
</v-btn>
</template>
@@ -139,7 +215,14 @@ const authorDisplay = computed(() => {
<v-tooltip location="top" :text="tm('buttons.viewInfo')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="secondary" @click.stop="$emit('show-info', plugin)">
<v-btn
v-bind="a"
icon
size="small"
variant="tonal"
color="secondary"
@click.stop="$emit('show-info', plugin)"
>
<v-icon>mdi-information</v-icon>
</v-btn>
</template>
@@ -147,7 +230,15 @@ const authorDisplay = computed(() => {
<v-tooltip location="top" :text="tm('buttons.uninstall')">
<template #activator="{ props: a }">
<v-btn v-bind="a" icon size="small" variant="tonal" color="error" @click.stop="$emit('uninstall', plugin.name)" v-if="!plugin.reserved">
<v-btn
v-bind="a"
icon
size="small"
variant="tonal"
color="error"
@click.stop="$emit('uninstall', plugin.name)"
v-if="!plugin.reserved"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
@@ -163,9 +254,16 @@ const authorDisplay = computed(() => {
:color="isPinned ? 'primary' : 'secondary'"
@click.stop="$emit('toggle-pin', plugin)"
:title="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
style="position:absolute; top:6px; right:6px; min-width:22px; width:22px; height:22px;"
style="
position: absolute;
top: 6px;
right: 6px;
min-width: 22px;
width: 22px;
height: 22px;
"
>
<v-icon size="14">{{ isPinned ? 'mdi-pin' : 'mdi-pin-outline' }}</v-icon>
<v-icon size="14">{{ isPinned ? "mdi-pin" : "mdi-pin-outline" }}</v-icon>
</v-btn>
</div>
</template>
@@ -197,7 +295,9 @@ const authorDisplay = computed(() => {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, opacity 0.2s ease;
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
.is-dragging {
@@ -2,8 +2,10 @@
<div class="provider-models-panel">
<div class="provider-models-head">
<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.configured") }}</h3>
<small v-if="availableCount" class="provider-models-subtitle"
>{{ tm("models.available") }} {{ availableCount }}</small
>
</div>
<v-text-field
v-model="modelSearchProxy"
@@ -25,7 +27,11 @@
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
{{
isSourceModified
? tm("providerSources.saveAndFetchModels")
: tm("providerSources.fetchModels")
}}
</v-btn>
<v-btn
color="primary"
@@ -34,18 +40,26 @@
size="small"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
{{ tm("models.manualAddButton") }}
</v-btn>
</div>
</div>
<v-list
density="compact"
class="provider-models-list"
>
<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
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"
@@ -55,97 +69,153 @@
<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="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-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="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>
<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>
<v-btn
icon="mdi-connection"
icon="mdi-delete"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:loading="isProviderTesting(entry.provider.id)"
v-bind="props"
@click.stop="emit('test-provider', entry.provider)"
color="error"
@click.stop="emit('delete-provider', entry.provider)"
></v-btn>
</template>
</v-tooltip>
<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>
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
</div>
</template>
</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>
<strong>{{ tm("models.tooltips.providerId") }}:</strong>
{{ entry.provider.id }}
</div>
<div>
<strong>{{ tm("models.tooltips.modelId") }}:</strong>
{{ entry.provider.model }}
</div>
</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
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="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>
<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="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>
<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>
<strong>{{ tm("models.tooltips.modelId") }}:</strong>
{{ entry.model }}
</div>
</div>
</v-tooltip>
</template>
@@ -153,7 +223,7 @@
<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>
<p class="text-grey mt-2">{{ tm("models.empty") }}</p>
</div>
</template>
</v-list>
@@ -161,73 +231,74 @@
</template>
<script setup>
import { computed } from 'vue'
import { normalizeTextInput } from '@/utils/inputValue'
import { computed } from "vue";
import { normalizeTextInput } from "@/utils/inputValue";
const props = defineProps({
entries: {
type: Array,
default: () => []
default: () => [],
},
availableCount: {
type: Number,
default: 0
default: 0,
},
modelSearch: {
type: String,
default: ''
default: "",
},
loadingModels: {
type: Boolean,
default: false
default: false,
},
isSourceModified: {
type: Boolean,
default: false
default: false,
},
supportsImageInput: {
type: Function,
required: true
required: true,
},
supportsToolCall: {
type: Function,
required: true
required: true,
},
supportsReasoning: {
type: Function,
required: true
required: true,
},
formatContextLimit: {
type: Function,
required: true
required: true,
},
testingProviders: {
type: Array,
default: () => []
default: () => [],
},
tm: {
type: Function,
required: true
}
})
required: true,
},
});
const emit = defineEmits([
'update:modelSearch',
'fetch-models',
'open-manual-model',
'open-provider-edit',
'toggle-provider-enable',
'test-provider',
'delete-provider',
'add-model-provider'
])
"update:modelSearch",
"fetch-models",
"open-manual-model",
"open-provider-edit",
"toggle-provider-enable",
"test-provider",
"delete-provider",
"add-model-provider",
]);
const modelSearchProxy = computed({
get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
})
set: (val) => emit("update:modelSearch", normalizeTextInput(val)),
});
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
const isProviderTesting = (providerId) =>
props.testingProviders.includes(providerId);
</script>
<style scoped>
@@ -279,7 +350,18 @@ const isProviderTesting = (providerId) => props.testingProviders.includes(provid
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;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
}
.provider-compact-item {
@@ -292,7 +374,9 @@ const isProviderTesting = (providerId) => props.testingProviders.includes(provid
.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;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
.cursor-pointer {
@@ -3,7 +3,9 @@
<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>
<h3 class="provider-sources-title">
{{ tm("providerSources.title") }}
</h3>
<v-chip size="x-small" variant="tonal" label>
{{ displayedProviderSources.length }}
</v-chip>
@@ -18,7 +20,7 @@
variant="tonal"
size="small"
>
{{ tm('providerSources.add') }}
{{ tm("providerSources.add") }}
</v-btn>
</template>
<v-list-item
@@ -29,7 +31,12 @@
>
<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-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>
@@ -38,7 +45,10 @@
</StyledMenu>
</div>
<div v-if="isMobile && displayedProviderSources.length > 0" class="provider-sources-mobile">
<div
v-if="isMobile && displayedProviderSources.length > 0"
class="provider-sources-mobile"
>
<div class="d-flex align-center ga-2">
<v-select
:model-value="selectedId"
@@ -57,7 +67,12 @@
<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-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>
</v-avatar>
</template>
@@ -75,25 +90,43 @@
</div>
</div>
<div v-else-if="displayedProviderSources.length > 0" class="provider-sources-list-wrap">
<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"
: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) }]"
: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-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>
<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
@@ -112,76 +145,78 @@
</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>
<p class="text-grey mt-2">{{ tm("providerSources.empty") }}</p>
</div>
</v-card>
</template>
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import StyledMenu from '@/components/shared/StyledMenu.vue'
import { computed } from "vue";
import { useDisplay } from "vuetify";
import StyledMenu from "@/components/shared/StyledMenu.vue";
const props = defineProps({
displayedProviderSources: {
type: Array,
default: () => []
default: () => [],
},
selectedProviderSource: {
type: Object,
default: null
default: null,
},
availableSourceTypes: {
type: Array,
default: () => []
default: () => [],
},
tm: {
type: Function,
required: true
required: true,
},
resolveSourceIcon: {
type: Function,
required: true
required: true,
},
getSourceDisplayName: {
type: Function,
required: true
}
})
required: true,
},
});
const emit = defineEmits([
'add-provider-source',
'select-provider-source',
'delete-provider-source'
])
"add-provider-source",
"select-provider-source",
"delete-provider-source",
]);
const { smAndDown } = useDisplay()
const selectedId = computed(() => props.selectedProviderSource?.id || null)
const isMobile = computed(() => smAndDown.value)
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
}))
)
source,
})),
);
const isActive = (source) => {
if (source.isPlaceholder) return false
return selectedId.value !== null && selectedId.value === source.id
}
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)
const matched = mobileSourceItems.value.find(
(item) => item.value === sourceId,
);
if (matched?.source) {
emitSelectSource(matched.source)
emitSelectSource(matched.source);
}
}
};
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 emitAddSource = (type) => emit("add-provider-source", type);
const emitSelectSource = (source) => emit("select-provider-source", source);
const emitDeleteSource = (source) => emit("delete-provider-source", source);
</script>
<style scoped>
@@ -233,7 +268,9 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
.provider-source-list-item {
margin-bottom: 2px;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
background: transparent;
}
@@ -214,10 +214,7 @@ function getSpecialSubtype(value) {
</script>
<template>
<v-card
v-if="shouldShowSection()"
class="config-reactor-card"
>
<v-card v-if="shouldShowSection()" class="config-reactor-card">
<v-card-text
v-if="metadata[metadataKey]?.type === 'object'"
class="config-section"
@@ -206,10 +206,7 @@ getCurrentVersion();
</v-alert>
<div v-else-if="changelogContent" class="changelog-content">
<div class="markdown-content">
<MarkdownRender
:content="changelogContent"
:typewriter="false"
/>
<MarkdownRender :content="changelogContent" :typewriter="false" />
</div>
</div>
</div>
@@ -1,15 +1,25 @@
<script setup>
import { useCommonStore } from '@/stores/common';
import axios from '@/utils/request';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { useCommonStore } from "@/stores/common";
import axios from "@/utils/request";
import { EventSourcePolyfill } from "event-source-polyfill";
</script>
<template>
<div class="console-displayer-wrapper" id="console-wrapper">
<div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter variant="flat" size="small"
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'" class="font-weight-medium">
<v-chip
v-for="level in logLevels"
:key="level"
:color="getLevelColor(level)"
filter
variant="flat"
size="small"
:text-color="
level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'
"
class="font-weight-medium"
>
{{ level }}
</v-chip>
</v-chip-group>
@@ -23,45 +33,53 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
></v-btn>
</div>
<div id="term" style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto; height: 100%">
</div>
<div
id="term"
style="
background-color: #1e1e1e;
padding: 16px;
border-radius: 8px;
overflow-y: auto;
height: 100%;
"
></div>
</div>
</template>
<script>
export default {
name: 'ConsoleDisplayer',
name: "ConsoleDisplayer",
data() {
return {
autoScroll: true,
isFullscreen: false,
logColorAnsiMap: {
'\u001b[1;34m': 'color: #39C5BB; font-weight: bold;',
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;',
'\u001b[1;33m': 'color: #FFFF00; font-weight: bold;',
'\u001b[31m': 'color: #FF0000;',
'\u001b[1;31m': 'color: #FF0000; font-weight: bold;',
'\u001b[0m': 'color: inherit; font-weight: normal;',
'\u001b[32m': 'color: #00FF00;',
'default': 'color: #FFFFFF;'
"\u001b[1;34m": "color: #39C5BB; font-weight: bold;",
"\u001b[1;36m": "color: #00FFFF; font-weight: bold;",
"\u001b[1;33m": "color: #FFFF00; font-weight: bold;",
"\u001b[31m": "color: #FF0000;",
"\u001b[1;31m": "color: #FF0000; font-weight: bold;",
"\u001b[0m": "color: inherit; font-weight: normal;",
"\u001b[32m": "color: #00FF00;",
default: "color: #FFFFFF;",
},
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
logLevels: ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
selectedLevels: [0, 1, 2, 3, 4],
levelColors: {
'DEBUG': 'grey',
'INFO': 'blue-lighten-3',
'WARNING': 'amber',
'ERROR': 'red',
'CRITICAL': 'purple'
DEBUG: "grey",
INFO: "blue-lighten-3",
WARNING: "amber",
ERROR: "red",
CRITICAL: "purple",
},
localLogCache: [],
eventSource: null,
retryTimer: null,
retryAttempts: 0,
maxRetryAttempts: 10,
baseRetryDelay: 1000,
lastEventId: null,
}
retryAttempts: 0,
maxRetryAttempts: 10,
baseRetryDelay: 1000,
lastEventId: null,
};
},
computed: {
commonStore() {
@@ -71,28 +89,31 @@ export default {
props: {
historyNum: {
type: String,
default: "-1"
default: "-1",
},
showLevelBtns: {
type: Boolean,
default: true
}
default: true,
},
},
watch: {
selectedLevels: {
handler() {
this.refreshDisplay();
},
deep: true
}
deep: true,
},
},
async mounted() {
await this.fetchLogHistory();
this.connectSSE();
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
document.addEventListener("fullscreenchange", this.handleFullscreenChange);
},
beforeUnmount() {
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
document.removeEventListener(
"fullscreenchange",
this.handleFullscreenChange,
);
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
@@ -111,23 +132,23 @@ export default {
}
console.log(`正在连接日志流... (尝试次数: ${this.retryAttempts})`);
const token = localStorage.getItem('token');
this.eventSource = new EventSourcePolyfill('/api/live-log', {
const token = localStorage.getItem("token");
this.eventSource = new EventSourcePolyfill("/api/live-log", {
headers: {
'Authorization': token ? `Bearer ${token}` : ''
Authorization: token ? `Bearer ${token}` : "",
},
heartbeatTimeout: 300000,
withCredentials: true
heartbeatTimeout: 300000,
withCredentials: true,
});
this.eventSource.onopen = () => {
console.log('日志流连接成功!');
console.log("日志流连接成功!");
this.retryAttempts = 0;
if (!this.lastEventId) {
this.fetchLogHistory();
this.fetchLogHistory();
}
};
@@ -140,35 +161,35 @@ export default {
const payload = JSON.parse(event.data);
this.processNewLogs([payload]);
} catch (e) {
console.error('解析日志失败:', e);
console.error("解析日志失败:", e);
}
};
this.eventSource.onerror = (err) => {
if (err.status === 401) {
console.error('鉴权失败 (401),可能是 Token 过期了。');
console.error("鉴权失败 (401),可能是 Token 过期了。");
} else {
console.warn('日志流连接错误:', err);
console.warn("日志流连接错误:", err);
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
this.eventSource.close();
this.eventSource = null;
}
if (this.retryAttempts >= this.maxRetryAttempts) {
console.error('❌ 已达到最大重试次数,停止重连。请刷新页面重试。');
return;
console.error("❌ 已达到最大重试次数,停止重连。请刷新页面重试。");
return;
}
const delay = Math.min(
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
30000
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
30000,
);
console.log(
`⏳ ${delay}ms 后尝试第 ${this.retryAttempts + 1} 次重连...`,
);
console.log(`⏳ ${delay}ms 后尝试第 ${this.retryAttempts + 1} 次重连...`);
if (this.retryTimer) {
clearTimeout(this.retryTimer);
@@ -177,11 +198,11 @@ export default {
this.retryTimer = setTimeout(async () => {
this.retryAttempts++;
if (!this.lastEventId) {
await this.fetchLogHistory();
await this.fetchLogHistory();
}
this.connectSSE();
}, delay);
};
@@ -192,52 +213,52 @@ export default {
let hasUpdate = false;
newLogs.forEach(log => {
const exists = this.localLogCache.some(existing =>
existing.time === log.time &&
existing.data === log.data &&
existing.level === log.level
newLogs.forEach((log) => {
const exists = this.localLogCache.some(
(existing) =>
existing.time === log.time &&
existing.data === log.data &&
existing.level === log.level,
);
if (!exists) {
this.localLogCache.push(log);
hasUpdate = true;
if (this.isLevelSelected(log.level)) {
this.printLog(log.data);
}
this.localLogCache.push(log);
hasUpdate = true;
if (this.isLevelSelected(log.level)) {
this.printLog(log.data);
}
}
});
if (hasUpdate) {
this.localLogCache.sort((a, b) => a.time - b.time);
const maxSize = this.commonStore.log_cache_max_len || 200;
if (this.localLogCache.length > maxSize) {
this.localLogCache.splice(0, this.localLogCache.length - maxSize);
this.localLogCache.splice(0, this.localLogCache.length - maxSize);
}
}
},
async fetchLogHistory() {
try {
const res = await axios.get('/api/log-history');
const res = await axios.get("/api/log-history");
if (res.data.data.logs && res.data.data.logs.length > 0) {
this.processNewLogs(res.data.data.logs);
}
} catch (err) {
console.error('Failed to fetch log history:', err);
console.error("Failed to fetch log history:", err);
}
},
getLevelColor(level) {
return this.levelColors[level] || 'grey';
return this.levelColors[level] || "grey";
},
isLevelSelected(level) {
for (let i = 0; i < this.selectedLevels.length; ++i) {
let level_ = this.logLevels[this.selectedLevels[i]]
const level_ = this.logLevels[this.selectedLevels[i]];
if (level_ === level) {
return true;
}
@@ -246,12 +267,12 @@ export default {
},
refreshDisplay() {
const termElement = document.getElementById('term');
const termElement = document.getElementById("term");
if (termElement) {
termElement.innerHTML = '';
termElement.innerHTML = "";
if (this.localLogCache && this.localLogCache.length > 0) {
this.localLogCache.forEach(logItem => {
this.localLogCache.forEach((logItem) => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
@@ -265,10 +286,12 @@ export default {
},
toggleFullscreen() {
const container = document.getElementById('console-wrapper');
const container = document.getElementById("console-wrapper");
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.error(`Error attempting to enable full-screen mode: ${err.message}`);
container.requestFullscreen().catch((err) => {
console.error(
`Error attempting to enable full-screen mode: ${err.message}`,
);
});
} else {
document.exitFullscreen();
@@ -280,31 +303,31 @@ export default {
},
printLog(log) {
let ele = document.getElementById('term')
const ele = document.getElementById("term");
if (!ele) {
return;
}
let span = document.createElement('pre')
let style = this.logColorAnsiMap['default']
for (let key in this.logColorAnsiMap) {
const span = document.createElement("pre");
let style = this.logColorAnsiMap["default"];
for (const key in this.logColorAnsiMap) {
if (log.startsWith(key)) {
style = this.logColorAnsiMap[key]
log = log.replace(key, '').replace('\u001b[0m', '')
break
style = this.logColorAnsiMap[key];
log = log.replace(key, "").replace("\u001b[0m", "");
break;
}
}
span.style = style
span.classList.add('console-log-line', 'fade-in')
span.style = style;
span.classList.add("console-log-line", "fade-in");
span.innerText = `${log}`;
ele.appendChild(span)
ele.appendChild(span);
if (this.autoScroll) {
ele.scrollTop = ele.scrollHeight
ele.scrollTop = ele.scrollHeight;
}
}
},
},
}
};
</script>
<style scoped>
@@ -329,13 +352,15 @@ export default {
}
.fullscreen-btn {
color: rgba(255, 255, 255, 0.7) !important; /* 提高在深色背景下的对比度 */
color: rgba(255, 255, 255, 0.7) !important; /* 提高在深色背景下的对比度 */
}
:deep(.console-log-line) {
display: block;
margin-bottom: 2px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, var(--astrbot-font-cjk-mono), monospace;
font-family:
SFMono-Regular, Menlo, Monaco, Consolas, var(--astrbot-font-cjk-mono),
monospace;
font-size: 12px;
white-space: pre-wrap;
}
+111 -29
View File
@@ -1,9 +1,15 @@
<template>
<div class="daily-quote">
<div class="quote-text" :style="{ color: 'var(--v-theme-on-surface)', opacity: 0.75 }">
<div
class="quote-text"
:style="{ color: 'var(--v-theme-on-surface)', opacity: 0.75 }"
>
"{{ currentQuote.text }}"
</div>
<div class="quote-author" :style="{ color: 'var(--v-theme-on-surface)', opacity: 0.5 }">
<div
class="quote-author"
:style="{ color: 'var(--v-theme-on-surface)', opacity: 0.5 }"
>
— {{ currentQuote.author }}
</div>
</div>
@@ -22,55 +28,130 @@ interface Quote {
const quotes: Record<string, Quote[]> = {
"en-US": [
{ text: "The best way to predict the future is to create it.", author: "Peter Drucker" },
{ text: "Code is like humor. When you have to explain it, it's bad.", author: "Cory House" },
{ text: "First, solve the problem. Then, write the code.", author: "John Johnson" },
{
text: "The best way to predict the future is to create it.",
author: "Peter Drucker",
},
{
text: "Code is like humor. When you have to explain it, it's bad.",
author: "Cory House",
},
{
text: "First, solve the problem. Then, write the code.",
author: "John Johnson",
},
{ text: "Simplicity is the soul of efficiency.", author: "Austin Freeman" },
{ text: "Make it work, make it right, make it fast.", author: "Kent Beck" },
{ text: "The only way to learn a new programming language is by writing programs in it.", author: "Dennis Ritchie" },
{ text: "Sometimes it pays to stay in bed on Monday, rather than spending the rest of the week debugging Monday's code.", author: "Dan Salomon" },
{ text: "Measuring programming progress by lines of code is like measuring aircraft building progress by weight.", author: "Bill Gates" },
{ text: "Walking on water and developing software from a specification are easy if both are frozen.", author: "Edward V. Berard" },
{
text: "The only way to learn a new programming language is by writing programs in it.",
author: "Dennis Ritchie",
},
{
text: "Sometimes it pays to stay in bed on Monday, rather than spending the rest of the week debugging Monday's code.",
author: "Dan Salomon",
},
{
text: "Measuring programming progress by lines of code is like measuring aircraft building progress by weight.",
author: "Bill Gates",
},
{
text: "Walking on water and developing software from a specification are easy if both are frozen.",
author: "Edward V. Berard",
},
{ text: "It works on my machine.", author: "Every Developer" },
{ text: "Talk is cheap. Show me the code.", author: "Linus Torvalds" },
{ text: "Any fool can write code that a computer can understand. Good programmers write code that humans can understand.", author: "Martin Fowler" },
{ text: "Programming is not about typing, it's about thinking.", author: "Cathy Apple" },
{
text: "Any fool can write code that a computer can understand. Good programmers write code that humans can understand.",
author: "Martin Fowler",
},
{
text: "Programming is not about typing, it's about thinking.",
author: "Cathy Apple",
},
{ text: "Deleted code is debugged code.", author: "Jeff Sickel" },
{ text: "The most disastrous thing you can learn is your first programming language.", author: "Alan Kay" },
{
text: "The most disastrous thing you can learn is your first programming language.",
author: "Alan Kay",
},
],
"zh-CN": [
{ text: "预测未来的最好方式是创造未来。", author: "彼得·德鲁克" },
{ text: "代码就像笑话,当你需要解释的时候,它就不好了。", author: "科里·豪斯" },
{
text: "代码就像笑话,当你需要解释的时候,它就不好了。",
author: "科里·豪斯",
},
{ text: "先解决问题,再写代码。", author: "约翰·约翰逊" },
{ text: "简单是效率的灵魂。", author: "奥斯汀·弗里曼" },
{ text: "让它工作,让它正确,让它快速。", author: "肯特·贝克" },
{ text: "学习新编程语言的唯一方法是用它写程序。", author: "丹尼斯·里奇" },
{ text: "有时候周一躺在床上比花一周时间调试周一的代码更划算。", author: "丹·萨洛蒙" },
{ text: "用代码行数衡量编程进度就像用重量衡量飞机建造进度一样。", author: "比尔·盖茨" },
{ text: "在水上行走和根据规格说明开发软件都很容易——如果两者都是冻结的话。", author: "爱德华·V·贝拉德" },
{
text: "有时候周一躺在床上比花一周时间调试周一的代码更划算。",
author: "丹·萨洛蒙",
},
{
text: "用代码行数衡量编程进度就像用重量衡量飞机建造进度一样。",
author: "比尔·盖茨",
},
{
text: "在水上行走和根据规格说明开发软件都很容易——如果两者都是冻结的话。",
author: "爱德华·V·贝拉德",
},
{ text: "在我机器上能运行。", author: "每个开发者" },
{ text: "空谈便宜。给我看代码。", author: "林纳斯·托瓦兹" },
{ text: "任何傻瓜都能写出计算机能理解的代码。好的程序员写的是人类能理解的代码。", author: "马丁·福勒" },
{
text: "任何傻瓜都能写出计算机能理解的代码。好的程序员写的是人类能理解的代码。",
author: "马丁·福勒",
},
{ text: "编程不是关于打字,是关于思考。", author: "凯西·阿普尔" },
{ text: "删除的代码就是调试过的代码。", author: "杰夫·西克尔" },
{ text: "你学第一门编程语言时学到的东西往往是最具灾难性的。", author: "艾伦·凯伊" },
{
text: "你学第一门编程语言时学到的东西往往是最具灾难性的。",
author: "艾伦·凯伊",
},
],
"ru-RU": [
{ text: "Лучший способ предсказать будущее — создать его.", author: "Питер Друкер" },
{ text: "Код как шутка. Когда нужно объяснять — значит плохо.", author: "Кори Хаус" },
{
text: "Лучший способ предсказать будущее — создать его.",
author: "Питер Друкер",
},
{
text: "Код как шутка. Когда нужно объяснять — значит плохо.",
author: "Кори Хаус",
},
{ text: "Сначала реши проблему. Потом пиши код.", author: "Джон Джонсон" },
{ text: "Простота — душа эффективности.", author: "Остин Фриман" },
{ text: "Сделай, чтобы работало, правильно, быстро.", author: "Кент Бек" },
{ text: "Единственный способ выучить новый язык программирования — писать на нём программы.", author: "Деннис Ритчи" },
{ text: "Иногда выгоднее понедельник проспать, чем неделю отлаживать код понедельника.", author: "Дэн Саломон" },
{ text: "Измерять прогресс программирования строками кода — как измерять постройку самолёта весом.", author: "Билл Гейтс" },
{ text: "Ходить по воде и разрабатывать ПО по спецификации легко, если оба заморожены.", author: "Эдвард В. Берард" },
{
text: "Единственный способ выучить новый язык программирования — писать на нём программы.",
author: "Деннис Ритчи",
},
{
text: "Иногда выгоднее понедельник проспать, чем неделю отлаживать код понедельника.",
author: "Дэн Саломон",
},
{
text: "Измерять прогресс программирования строками кода — как измерять постройку самолёта весом.",
author: "Билл Гейтс",
},
{
text: "Ходить по воде и разрабатывать ПО по спецификации легко, если оба заморожены.",
author: "Эдвард В. Берард",
},
{ text: "На моей машине работает.", author: "Каждый разработчик" },
{ text: "Разговоры дёшевы. Покажи мне код.", author: "Линус Торвальдс" },
{ text: "Любой дурак может написать код, который поймёт компьютер. Хорошие программисты пишут код, который поймут люди.", author: "Мартин Фаулер" },
{ text: "Программирование — это не о печатании, а о мышлении.", author: "Кэти Эппл" },
{
text: "Любой дурак может написать код, который поймёт компьютер. Хорошие программисты пишут код, который поймут люди.",
author: "Мартин Фаулер",
},
{
text: "Программирование — это не о печатании, а о мышлении.",
author: "Кэти Эппл",
},
{ text: "Удалённый код — это отлаженный код.", author: "Джефф Сайкел" },
{ text: "Самое катастрофическое, что можно выучить — свой первый язык программирования.", author: "Алан Кей" },
{
text: "Самое катастрофическое, что можно выучить — свой первый язык программирования.",
author: "Алан Кей",
},
],
};
@@ -82,7 +163,8 @@ const getQuoteOfTheDay = (): Quote => {
// Use date as seed so the quote stays the same for the whole day
const today = new Date();
const dayOfYear = Math.floor(
(today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) / 86400000
(today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) /
86400000,
);
const index = dayOfYear % langQuotes.length;
return langQuotes[index];
@@ -94,7 +176,7 @@ watch(
() => {
currentQuote.value = getQuoteOfTheDay();
},
{ immediate: true }
{ immediate: true },
);
</script>
+9 -4
View File
@@ -96,8 +96,13 @@ const formatTitle = (title: string) => {
}
@keyframes logoBreath {
0%, 100% { opacity: 0.75; }
50% { opacity: 1; }
0%,
100% {
opacity: 0.75;
}
50% {
opacity: 1;
}
}
.logo-text {
@@ -121,7 +126,8 @@ const formatTitle = (title: string) => {
/* Dark mode: cyan glow */
.v-theme--bluebusinessdarktheme .logo-title {
color: rgba(0, 242, 255, 0.95) !important;
text-shadow: 0 0 12px rgba(0, 242, 255, 0.5),
text-shadow:
0 0 12px rgba(0, 242, 255, 0.5),
0 0 30px rgba(0, 242, 255, 0.2);
}
@@ -154,7 +160,6 @@ const formatTitle = (title: string) => {
}
}
@media (max-width: 520px) {
.logo-content {
gap: 15px;
+2 -2
View File
@@ -173,8 +173,8 @@ export function useSessions(chatboxMode = false) {
);
const currentSessionDeleted = Boolean(
currentSessionId &&
sessionIds.includes(currentSessionId) &&
!failedSessionIds.has(currentSessionId),
sessionIds.includes(currentSessionId) &&
!failedSessionIds.has(currentSessionId),
);
if (currentSessionDeleted) {
+11 -3
View File
@@ -18,7 +18,9 @@ const customizer = useCustomizerStore();
const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
const isCurrentChatRoute = computed(
() => route.path === "/chat" || route.path.startsWith("/chat/"),
);
const isChatPage = computed(() => {
return route.path.startsWith("/chat");
@@ -52,7 +54,10 @@ const checkMigration = async (): Promise<boolean> => {
return true;
}
} catch (error) {
console.warn("Failed to check migration status (backend may not be running):", error);
console.warn(
"Failed to check migration status (backend may not be running):",
error,
);
}
return false;
};
@@ -78,7 +83,10 @@ const maybeShowFirstNotice = async () => {
localStorage.setItem(FIRST_NOTICE_SEEN_KEY, "1");
} catch (error) {
console.warn("Failed to load first notice (backend may not be running):", error);
console.warn(
"Failed to load first notice (backend may not be running):",
error,
);
}
};
@@ -855,10 +855,7 @@ const isChristmas = computed(() => {
"
>
<div class="markdown-content">
<MarkdownRender
:content="releaseMessage"
:typewriter="false"
/>
<MarkdownRender :content="releaseMessage" :typewriter="false" />
</div>
</div>
@@ -1024,10 +1021,7 @@ const isChristmas = computed(() => {
</v-card-title>
<v-card-text style="font-size: 14px; max-height: 400px; overflow-y: auto">
<div class="markdown-content">
<MarkdownRender
:content="selectedReleaseNotes"
:typewriter="false"
/>
<MarkdownRender :content="selectedReleaseNotes" :typewriter="false" />
</div>
</v-card-text>
<v-card-actions>
+3 -1
View File
@@ -49,7 +49,9 @@ export const useCustomizerStore = defineStore("customizer", {
// 应用系统主题(用于自动同步)
APPLY_SYSTEM_THEME() {
if (typeof window === "undefined") return;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
this.SET_UI_THEME(prefersDark ? DARK_THEME_NAME : LIGHT_THEME_NAME);
},
TOGGLE_CHAT_SIDEBAR() {
+2 -2
View File
@@ -17,8 +17,8 @@ const BlueBusinessDarkTheme: ThemeTypes = {
"border-color": "#1F2124",
"carousel-control-size": 10,
// Aliases for custom text colors — maps to standard MD3 on-surface
"primaryText": "rgba(226, 226, 231, 0.92)",
"secondaryText": "rgba(226, 226, 231, 0.65)",
primaryText: "rgba(226, 226, 231, 0.92)",
secondaryText: "rgba(226, 226, 231, 0.65)",
},
colors: {
// === MD3 Core Colors (Cherenkov Energy - Deep Reactor) ===
+1 -1
View File
@@ -2,4 +2,4 @@ export const LIGHT_THEME_NAME = "BlueBusinessTheme";
export const DARK_THEME_NAME = "BlueBusinessDarkTheme";
// Theme related types
export type ThemeMode = "light" | "dark" | "auto";
export type ThemeMode = "light" | "dark" | "auto";
+1 -4
View File
@@ -149,10 +149,7 @@
>
<div class="editor-reactor-container">
<v-card class="editor-glass-card">
<v-toolbar
class="editor-toolbar"
elevation="0"
>
<v-toolbar class="editor-toolbar" elevation="0">
<v-btn icon @click="codeEditorDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
+64 -13
View File
@@ -22,7 +22,10 @@ async function pipInstall() {
loading.value = true;
status.value = "";
try {
const res = await axios.post("/api/console/pip_install", pipInstallPayload.value);
const res = await axios.post(
"/api/console/pip_install",
pipInstallPayload.value,
);
if (res.data?.status === "ok") {
status.value = tm("pipInstall.success");
} else {
@@ -41,7 +44,13 @@ async function pipInstall() {
<div class="console-topbar">
<div class="topbar-left">
<div class="topbar-title">{{ tm("title") }}</div>
<v-alert type="info" variant="tonal" density="compact" class="mt-2" style="max-width: 600px">
<v-alert
type="info"
variant="tonal"
density="compact"
class="mt-2"
style="max-width: 600px"
>
{{ tm("debugHint.text") }}
</v-alert>
</div>
@@ -54,7 +63,11 @@ async function pipInstall() {
/>
<v-switch
v-model="autoScrollEnabled"
:label="autoScrollEnabled ? tm('autoScroll.enabled') : tm('autoScroll.disabled')"
:label="
autoScrollEnabled
? tm('autoScroll.enabled')
: tm('autoScroll.disabled')
"
hide-details
density="compact"
color="primary"
@@ -71,14 +84,29 @@ async function pipInstall() {
<span class="text-h5">{{ tm("pipInstall.dialogTitle") }}</span>
</v-card-title>
<v-card-text>
<v-text-field v-model="pipInstallPayload.package" :label="tm('pipInstall.packageLabel')" variant="outlined" />
<v-text-field v-model="pipInstallPayload.mirror" :label="tm('pipInstall.mirrorLabel')" variant="outlined" />
<v-text-field
v-model="pipInstallPayload.package"
:label="tm('pipInstall.packageLabel')"
variant="outlined"
/>
<v-text-field
v-model="pipInstallPayload.mirror"
:label="tm('pipInstall.mirrorLabel')"
variant="outlined"
/>
<small>{{ tm("pipInstall.mirrorHint") }}</small>
<div><small>{{ status }}</small></div>
<div>
<small>{{ status }}</small>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="blue-darken-1" variant="text" :loading="loading" @click="pipInstall">
<v-btn
color="blue-darken-1"
variant="text"
:loading="loading"
@click="pipInstall"
>
{{ tm("pipInstall.installButton") }}
</v-btn>
</v-card-actions>
@@ -86,7 +114,14 @@ async function pipInstall() {
</v-dialog>
</div>
</div>
<div class="console-content" :style="isFullscreen ? 'height: calc(100vh - 120px)' : 'height: calc(100vh - 220px)'">
<div
class="console-content"
:style="
isFullscreen
? 'height: calc(100vh - 120px)'
: 'height: calc(100vh - 220px)'
"
>
<ConsoleDisplayer ref="consoleDisplayer" style="height: 100%" />
</div>
</div>
@@ -154,7 +189,11 @@ export default {
flex-shrink: 0;
}
.topbar-left { display: flex; flex-direction: column; gap: 4px; }
.topbar-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.topbar-title {
font-size: 18px;
@@ -163,7 +202,11 @@ export default {
-webkit-text-fill-color: var(--console-primary);
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.console-content {
flex: 1;
@@ -185,8 +228,16 @@ export default {
}
@media (max-width: 900px) {
.console-topbar { flex-direction: column; align-items: flex-start; }
.topbar-right { width: 100%; }
.console-page { gap: 12px; padding: 12px; }
.console-topbar {
flex-direction: column;
align-items: flex-start;
}
.topbar-right {
width: 100%;
}
.console-page {
gap: 12px;
padding: 12px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+525 -175
View File
@@ -2,21 +2,36 @@
<div class="cron-page">
<div class="cron-topbar">
<div class="topbar-left">
<div class="topbar-title">{{ tm('page.title') }}</div>
<div class="topbar-desc">{{ tm('page.subtitle') }}</div>
<div class="topbar-title">{{ tm("page.title") }}</div>
<div class="topbar-desc">{{ tm("page.subtitle") }}</div>
</div>
<div class="topbar-right">
<v-btn variant="text" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadJobs">
{{ tm('actions.refresh') }}
<v-btn
variant="text"
color="primary"
:loading="loading"
prepend-icon="mdi-refresh"
@click="loadJobs"
>
{{ tm("actions.refresh") }}
</v-btn>
<v-btn variant="tonal" color="primary" prepend-icon="mdi-plus" @click="openCreate">
{{ tm('actions.create') }}
<v-btn
variant="tonal"
color="primary"
prepend-icon="mdi-plus"
@click="openCreate"
>
{{ tm("actions.create") }}
</v-btn>
</div>
</div>
<div class="cron-overview-grid">
<div v-for="card in overviewCards" :key="card.label" class="cron-overview-card">
<div
v-for="card in overviewCards"
:key="card.label"
class="cron-overview-card"
>
<div class="cron-card-icon">
<v-icon size="18">{{ card.icon }}</v-icon>
</div>
@@ -28,38 +43,57 @@
<div class="cron-section-head">
<div>
<div class="cron-section-title">{{ tm('section.platforms.title') }}</div>
<div class="cron-section-title">
{{ tm("section.platforms.title") }}
</div>
<div class="cron-section-subtitle">
{{ proactivePlatforms.length ? tm('page.proactive.supported', { platforms: proactivePlatformText }) : tm('page.proactive.unsupported') }}
{{
proactivePlatforms.length
? tm("page.proactive.supported", {
platforms: proactivePlatformText,
})
: tm("page.proactive.unsupported")
}}
</div>
</div>
</div>
<section v-if="proactivePlatforms.length" class="platform-section">
<div class="platform-chip-wrap">
<v-chip v-for="platform in proactivePlatforms" :key="platform.id" size="small" variant="tonal" color="primary">
<v-chip
v-for="platform in proactivePlatforms"
:key="platform.id"
size="small"
variant="tonal"
color="primary"
>
{{ platform.display_name || platform.name }} · {{ platform.id }}
</v-chip>
</div>
</section>
<div v-else class="cron-empty">{{ tm('page.proactive.unsupported') }}</div>
<div v-else class="cron-empty">{{ tm("page.proactive.unsupported") }}</div>
<div class="cron-section-head">
<div>
<div class="cron-section-title">{{ tm('table.title') }}</div>
<div class="cron-section-subtitle">{{ tm('table.subtitle') }}</div>
<div class="cron-section-title">{{ tm("table.title") }}</div>
<div class="cron-section-subtitle">{{ tm("table.subtitle") }}</div>
</div>
</div>
<div class="cron-content">
<div v-if="loading && !jobs.length" class="state-panel">
<v-progress-circular indeterminate size="22" width="2" color="primary" />
<span>{{ tm('actions.refresh') }}...</span>
<v-progress-circular
indeterminate
size="22"
width="2"
color="primary"
/>
<span>{{ tm("actions.refresh") }}...</span>
</div>
<div v-else-if="!jobs.length" class="state-panel">
<v-icon size="20" color="primary">mdi-calendar-blank-outline</v-icon>
<span>{{ tm('table.empty') }}</span>
<span>{{ tm("table.empty") }}</span>
</div>
<div v-else class="task-table-wrap">
@@ -75,23 +109,31 @@
</colgroup>
<thead>
<tr>
<th>{{ tm('table.headers.name') }}</th>
<th>{{ tm('table.headers.type') }}</th>
<th>{{ tm('table.headers.cron') }}</th>
<th>{{ tm('table.headers.session') }}</th>
<th>{{ tm('table.headers.nextRun') }}</th>
<th>{{ tm('table.headers.lastRun') }}</th>
<th class="actions-col">{{ tm('table.headers.actions') }}</th>
<th>{{ tm("table.headers.name") }}</th>
<th>{{ tm("table.headers.type") }}</th>
<th>{{ tm("table.headers.cron") }}</th>
<th>{{ tm("table.headers.session") }}</th>
<th>{{ tm("table.headers.nextRun") }}</th>
<th>{{ tm("table.headers.lastRun") }}</th>
<th class="actions-col">{{ tm("table.headers.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in sortedJobs" :key="item.job_id">
<td class="name-col">
<div class="task-name">{{ item.name || tm('table.notAvailable') }}</div>
<div class="task-subline">{{ item.description || item.job_id }}</div>
<div class="task-name">
{{ item.name || tm("table.notAvailable") }}
</div>
<div class="task-subline">
{{ item.description || item.job_id }}
</div>
</td>
<td>
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
<v-chip
size="small"
:color="item.run_once ? 'orange' : 'primary'"
variant="tonal"
>
{{ jobTypeLabel(item) }}
</v-chip>
</td>
@@ -100,19 +142,36 @@
<div class="task-subline">{{ scheduleMeta(item) }}</div>
</td>
<td>
<div class="task-session">{{ item.session || tm('table.notAvailable') }}</div>
<div class="task-session">
{{ item.session || tm("table.notAvailable") }}
</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.next_run_time) }}</div>
<div class="task-text">
{{ formatTime(item.next_run_time) }}
</div>
</td>
<td>
<div class="task-text">{{ formatTime(item.last_run_at) }}</div>
</td>
<td class="actions-col">
<div class="table-actions">
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary" class="mt-0" @change="toggleJob(item)" />
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
<v-switch
v-model="item.enabled"
inset
density="compact"
hide-details
color="primary"
class="mt-0"
@change="toggleJob(item)"
/>
<v-btn
size="small"
variant="text"
color="error"
@click="deleteJob(item)"
>
{{ tm("actions.delete") }}
</v-btn>
</div>
</td>
@@ -128,23 +187,81 @@
<v-dialog v-model="createDialog" max-width="640">
<v-card class="cron-dialog-card">
<v-card-title class="text-h6 pt-5 px-5">{{ tm('form.title') }}</v-card-title>
<v-card-subtitle class="px-5 text-body-2 text-medium-emphasis">{{ tm('form.chatHint') }}</v-card-subtitle>
<v-card-title class="text-h6 pt-5 px-5">{{
tm("form.title")
}}</v-card-title>
<v-card-subtitle class="px-5 text-body-2 text-medium-emphasis">{{
tm("form.chatHint")
}}</v-card-subtitle>
<v-card-text class="px-5 pb-2">
<div class="cron-form-grid cron-form-grid--single">
<v-switch v-model="newJob.run_once" :label="tm('form.runOnce')" inset color="primary" hide-details />
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
<v-text-field v-if="!newJob.run_once" v-model="newJob.cron_expression" :label="tm('form.cron')" :placeholder="tm('form.cronPlaceholder')" variant="outlined" density="comfortable" />
<v-text-field v-else v-model="newJob.run_at" :label="tm('form.runAt')" type="datetime-local" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.session" :label="tm('form.session')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.timezone" :label="tm('form.timezone')" variant="outlined" density="comfortable" />
<v-switch v-model="newJob.enabled" :label="tm('form.enabled')" inset color="primary" hide-details />
<v-switch
v-model="newJob.run_once"
:label="tm('form.runOnce')"
inset
color="primary"
hide-details
/>
<v-text-field
v-model="newJob.name"
:label="tm('form.name')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-model="newJob.note"
:label="tm('form.note')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-if="!newJob.run_once"
v-model="newJob.cron_expression"
:label="tm('form.cron')"
:placeholder="tm('form.cronPlaceholder')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-else
v-model="newJob.run_at"
:label="tm('form.runAt')"
type="datetime-local"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-model="newJob.session"
:label="tm('form.session')"
variant="outlined"
density="comfortable"
/>
<v-text-field
v-model="newJob.timezone"
:label="tm('form.timezone')"
variant="outlined"
density="comfortable"
/>
<v-switch
v-model="newJob.enabled"
:label="tm('form.enabled')"
inset
color="primary"
hide-details
/>
</div>
</v-card-text>
<v-card-actions class="justify-end px-5 pb-5">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">{{ tm('actions.submit') }}</v-btn>
<v-btn variant="text" @click="createDialog = false">{{
tm("actions.cancel")
}}</v-btn>
<v-btn
variant="tonal"
color="primary"
:loading="creating"
@click="createJob"
>{{ tm("actions.submit") }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
@@ -152,153 +269,247 @@
</template>
<script setup lang="ts">
import axios from '@/utils/request'
import { computed, onMounted, ref } from 'vue'
import { useTheme } from 'vuetify'
import { useModuleI18n } from '@/i18n/composables'
import axios from "@/utils/request";
import { computed, onMounted, ref } from "vue";
import { useTheme } from "vuetify";
import { useModuleI18n } from "@/i18n/composables";
const { tm } = useModuleI18n('features/cron')
const theme = useTheme()
const { tm } = useModuleI18n("features/cron");
const theme = useTheme();
const isDark = computed(() => theme.global.current.value.dark)
const loading = ref(false)
const jobs = ref<any[]>([])
const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([])
const createDialog = ref(false)
const creating = ref(false)
const isDark = computed(() => theme.global.current.value.dark);
const loading = ref(false);
const jobs = ref<any[]>([]);
const proactivePlatforms = ref<
{ id: string; name: string; display_name?: string }[]
>([]);
const createDialog = ref(false);
const creating = ref(false);
const newJob = ref({
run_once: false,
name: '',
note: '',
cron_expression: '',
run_at: '',
session: '',
timezone: '',
enabled: true
})
name: "",
note: "",
cron_expression: "",
run_at: "",
session: "",
timezone: "",
enabled: true,
});
const snackbar = ref({ show: false, message: '', color: 'success' })
const snackbar = ref({ show: false, message: "", color: "success" });
const proactivePlatformText = computed(() =>
proactivePlatforms.value.map((p) => `${p.display_name || p.name}(${p.id})`).join(' / ')
)
proactivePlatforms.value
.map((p) => `${p.display_name || p.name}(${p.id})`)
.join(" / "),
);
const enabledJobsCount = computed(() => jobs.value.filter((job) => job.enabled).length)
const runOnceCount = computed(() => jobs.value.filter((job) => job.run_once).length)
const recurringCount = computed(() => jobs.value.filter((job) => !job.run_once).length)
const enabledJobsCount = computed(
() => jobs.value.filter((job) => job.enabled).length,
);
const runOnceCount = computed(
() => jobs.value.filter((job) => job.run_once).length,
);
const recurringCount = computed(
() => jobs.value.filter((job) => !job.run_once).length,
);
const sortedJobs = computed(() =>
[...jobs.value].sort((a, b) => {
if (a.enabled !== b.enabled) { return a.enabled ? -1 : 1 }
const nextA = parseTimeValue(a.next_run_time ?? a.run_at)
const nextB = parseTimeValue(b.next_run_time ?? b.run_at)
if (nextA !== nextB) {
if (!nextA) return 1
if (!nextB) return -1
return nextA - nextB
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1;
}
return String(a.name || '').localeCompare(String(b.name || ''))
})
)
const nextA = parseTimeValue(a.next_run_time ?? a.run_at);
const nextB = parseTimeValue(b.next_run_time ?? b.run_at);
if (nextA !== nextB) {
if (!nextA) return 1;
if (!nextB) return -1;
return nextA - nextB;
}
return String(a.name || "").localeCompare(String(b.name || ""));
}),
);
const overviewCards = computed(() => [
{ label: tm('overview.totalTasks'), value: String(jobs.value.length), note: tm('overview.totalTasksNote'), icon: 'mdi-calendar-multiple' },
{ label: tm('overview.enabledTasks'), value: String(enabledJobsCount.value), note: tm('overview.enabledTasksNote'), icon: 'mdi-check-circle-outline' }
])
{
label: tm("overview.totalTasks"),
value: String(jobs.value.length),
note: tm("overview.totalTasksNote"),
icon: "mdi-calendar-multiple",
},
{
label: tm("overview.enabledTasks"),
value: String(enabledJobsCount.value),
note: tm("overview.enabledTasksNote"),
icon: "mdi-check-circle-outline",
},
]);
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
snackbar.value = { show: true, message, color }
function toast(
message: string,
color: "success" | "error" | "warning" = "success",
) {
snackbar.value = { show: true, message, color };
}
function parseTimeValue(value: any): number {
if (!value) return 0
const ts = new Date(value).getTime()
return Number.isNaN(ts) ? 0 : ts
if (!value) return 0;
const ts = new Date(value).getTime();
return Number.isNaN(ts) ? 0 : ts;
}
function formatTime(val: any): string {
if (!val) return tm('table.notAvailable')
try { return new Date(val).toLocaleString() } catch { return String(val) }
if (!val) return tm("table.notAvailable");
try {
return new Date(val).toLocaleString();
} catch {
return String(val);
}
}
function jobTypeLabel(item: any): string {
if (item.run_once) return tm('table.type.once')
const type = item.job_type || 'active_agent'
const map: Record<string, string> = { active_agent: tm('table.type.activeAgent'), workflow: tm('table.type.workflow') }
return map[type] || tm('table.type.unknown', { type })
if (item.run_once) return tm("table.type.once");
const type = item.job_type || "active_agent";
const map: Record<string, string> = {
active_agent: tm("table.type.activeAgent"),
workflow: tm("table.type.workflow"),
};
return map[type] || tm("table.type.unknown", { type });
}
function scheduleLabel(item: any): string {
if (item.run_once) return formatTime(item.run_at)
return item.cron_expression || tm('table.notAvailable')
if (item.run_once) return formatTime(item.run_at);
return item.cron_expression || tm("table.notAvailable");
}
function scheduleMeta(item: any): string {
if (item.run_once) return tm('table.type.once')
return item.timezone || tm('table.timezoneLocal')
if (item.run_once) return tm("table.type.once");
return item.timezone || tm("table.timezoneLocal");
}
async function loadJobs() {
loading.value = true
loading.value = true;
try {
const res = await axios.get('/api/cron/jobs')
if (res.data.status === 'ok') {
const data = Array.isArray(res.data.data) ? res.data.data : []
jobs.value = data.map((job: any) => ({ ...job, session: job?.payload?.session || job?.session || '' }))
} else { toast(res.data.message || tm('messages.loadFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.loadFailed'), 'error') }
finally { loading.value = false }
const res = await axios.get("/api/cron/jobs");
if (res.data.status === "ok") {
const data = Array.isArray(res.data.data) ? res.data.data : [];
jobs.value = data.map((job: any) => ({
...job,
session: job?.payload?.session || job?.session || "",
}));
} else {
toast(res.data.message || tm("messages.loadFailed"), "error");
}
} catch (e: any) {
toast(e?.response?.data?.message || tm("messages.loadFailed"), "error");
} finally {
loading.value = false;
}
}
async function loadPlatforms() {
try {
const res = await axios.get('/api/platform/stats')
if (res.data.status === 'ok' && Array.isArray(res.data.data?.platforms)) {
proactivePlatforms.value = res.data.data.platforms.filter((p: any) => p?.meta?.support_proactive_message).map((p: any) => ({
id: p?.id || p?.meta?.id || 'unknown',
name: p?.meta?.name || p?.type || '',
display_name: p?.meta?.display_name || p?.display_name
}))
const res = await axios.get("/api/platform/stats");
if (res.data.status === "ok" && Array.isArray(res.data.data?.platforms)) {
proactivePlatforms.value = res.data.data.platforms
.filter((p: any) => p?.meta?.support_proactive_message)
.map((p: any) => ({
id: p?.id || p?.meta?.id || "unknown",
name: p?.meta?.name || p?.type || "",
display_name: p?.meta?.display_name || p?.display_name,
}));
}
} catch { /* Ignore */ }
} catch {
/* Ignore */
}
}
async function toggleJob(job: any) {
try {
const res = await axios.patch(`/api/cron/jobs/${job.job_id}`, { enabled: job.enabled })
if (res.data.status !== 'ok') { toast(res.data.message || tm('messages.updateFailed'), 'error'); await loadJobs() }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error'); await loadJobs() }
const res = await axios.patch(`/api/cron/jobs/${job.job_id}`, {
enabled: job.enabled,
});
if (res.data.status !== "ok") {
toast(res.data.message || tm("messages.updateFailed"), "error");
await loadJobs();
}
} catch (e: any) {
toast(e?.response?.data?.message || tm("messages.updateFailed"), "error");
await loadJobs();
}
}
async function deleteJob(job: any) {
try {
const res = await axios.delete(`/api/cron/jobs/${job.job_id}`)
if (res.data.status === 'ok') { toast(tm('messages.deleteSuccess')); jobs.value = jobs.value.filter((item) => item.job_id !== job.job_id) }
else { toast(res.data.message || tm('messages.deleteFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.deleteFailed'), 'error') }
const res = await axios.delete(`/api/cron/jobs/${job.job_id}`);
if (res.data.status === "ok") {
toast(tm("messages.deleteSuccess"));
jobs.value = jobs.value.filter((item) => item.job_id !== job.job_id);
} else {
toast(res.data.message || tm("messages.deleteFailed"), "error");
}
} catch (e: any) {
toast(e?.response?.data?.message || tm("messages.deleteFailed"), "error");
}
}
function openCreate() { resetNewJob(); createDialog.value = true }
function openCreate() {
resetNewJob();
createDialog.value = true;
}
function resetNewJob() {
newJob.value = { run_once: false, name: '', note: '', cron_expression: '', run_at: '', session: '', timezone: '', enabled: true }
newJob.value = {
run_once: false,
name: "",
note: "",
cron_expression: "",
run_at: "",
session: "",
timezone: "",
enabled: true,
};
}
async function createJob() {
if (!newJob.value.session) { toast(tm('messages.sessionRequired'), 'warning'); return }
if (!newJob.value.note) { toast(tm('messages.noteRequired'), 'warning'); return }
if (!newJob.value.run_once && !newJob.value.cron_expression) { toast(tm('messages.cronRequired'), 'warning'); return }
if (newJob.value.run_once && !newJob.value.run_at) { toast(tm('messages.runAtRequired'), 'warning'); return }
creating.value = true
if (!newJob.value.session) {
toast(tm("messages.sessionRequired"), "warning");
return;
}
if (!newJob.value.note) {
toast(tm("messages.noteRequired"), "warning");
return;
}
if (!newJob.value.run_once && !newJob.value.cron_expression) {
toast(tm("messages.cronRequired"), "warning");
return;
}
if (newJob.value.run_once && !newJob.value.run_at) {
toast(tm("messages.runAtRequired"), "warning");
return;
}
creating.value = true;
try {
const res = await axios.post('/api/cron/jobs', { ...newJob.value })
if (res.data.status === 'ok') { toast(tm('messages.createSuccess')); createDialog.value = false; resetNewJob(); await loadJobs() }
else { toast(res.data.message || tm('messages.createFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.createFailed'), 'error') }
finally { creating.value = false }
const res = await axios.post("/api/cron/jobs", { ...newJob.value });
if (res.data.status === "ok") {
toast(tm("messages.createSuccess"));
createDialog.value = false;
resetNewJob();
await loadJobs();
} else {
toast(res.data.message || tm("messages.createFailed"), "error");
}
} catch (e: any) {
toast(e?.response?.data?.message || tm("messages.createFailed"), "error");
} finally {
creating.value = false;
}
}
onMounted(() => { loadJobs(); loadPlatforms() })
onMounted(() => {
loadJobs();
loadPlatforms();
});
</script>
<style scoped>
@@ -358,7 +569,11 @@ onMounted(() => { loadJobs(); loadPlatforms() })
flex-shrink: 0;
}
.topbar-left { display: flex; flex-direction: column; gap: 4px; }
.topbar-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.topbar-title {
font-size: 18px;
@@ -376,7 +591,11 @@ onMounted(() => { loadJobs(); loadPlatforms() })
max-width: 60ch;
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.cron-overview-grid {
display: grid;
@@ -405,7 +624,12 @@ onMounted(() => { loadJobs(); loadPlatforms() })
color: var(--cron-primary);
}
.cron-card-label { margin-top: 12px; color: var(--cron-muted); font-size: 13px; font-weight: 500; }
.cron-card-label {
margin-top: 12px;
color: var(--cron-muted);
font-size: 13px;
font-weight: 500;
}
.cron-card-value {
margin-top: 8px;
@@ -417,13 +641,35 @@ onMounted(() => { loadJobs(); loadPlatforms() })
color: var(--cron-text);
}
.cron-card-note { margin-top: 8px; color: var(--cron-subtle); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.cron-card-note {
margin-top: 8px;
color: var(--cron-subtle);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.cron-section-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; }
.cron-section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
}
.cron-section-title { font-size: 19px; font-weight: 650; letter-spacing: -0.02em; line-height: 1.3; overflow-wrap: anywhere; }
.cron-section-title {
font-size: 19px;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.3;
overflow-wrap: anywhere;
}
.cron-section-subtitle { margin-top: 6px; color: var(--cron-muted); font-size: 13px; line-height: 1.6; }
.cron-section-subtitle {
margin-top: 6px;
color: var(--cron-muted);
font-size: 13px;
line-height: 1.6;
}
.cron-content {
flex: 1;
@@ -438,24 +684,62 @@ onMounted(() => { loadJobs(); loadPlatforms() })
box-shadow: var(--cron-shadow);
}
.cron-empty { color: var(--cron-muted); font-size: 14px; padding: 16px 20px; background: var(--cron-panel-bg); border: 1px solid var(--cron-border); border-radius: 12px; }
.cron-empty {
color: var(--cron-muted);
font-size: 14px;
padding: 16px 20px;
background: var(--cron-panel-bg);
border: 1px solid var(--cron-border);
border-radius: 12px;
}
.platform-section { padding: 16px 20px; background: var(--cron-panel-bg); border: 1px solid var(--cron-border); border-radius: 12px; }
.platform-section {
padding: 16px 20px;
background: var(--cron-panel-bg);
border: 1px solid var(--cron-border);
border-radius: 12px;
}
.platform-chip-wrap { display: flex; flex-wrap: wrap; gap: 8px; }
.platform-chip-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.task-surface { min-width: 0; }
.task-surface {
min-width: 0;
}
.task-table-wrap { overflow: auto; background: var(--cron-card-bg); }
.task-table-wrap {
overflow: auto;
background: var(--cron-card-bg);
}
.task-table { width: 100%; min-width: 1120px; border-collapse: collapse; }
.task-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
}
.task-table .col-name { width: 220px; }
.task-table .col-type { width: 120px; }
.task-table .col-cron { width: 260px; }
.task-table .col-session { width: 340px; }
.task-table .col-next-run, .task-table .col-last-run { width: 180px; }
.task-table .col-actions { width: 170px; }
.task-table .col-name {
width: 220px;
}
.task-table .col-type {
width: 120px;
}
.task-table .col-cron {
width: 260px;
}
.task-table .col-session {
width: 340px;
}
.task-table .col-next-run,
.task-table .col-last-run {
width: 180px;
}
.task-table .col-actions {
width: 170px;
}
.task-table th {
padding: 14px 16px;
@@ -470,21 +754,57 @@ onMounted(() => { loadJobs(); loadPlatforms() })
white-space: nowrap;
}
.task-table td { padding: 16px; vertical-align: top; border-bottom: 1px solid var(--cron-border); color: var(--cron-text); }
.task-table tbody tr:last-child td { border-bottom: 0; }
.task-table td {
padding: 16px;
vertical-align: top;
border-bottom: 1px solid var(--cron-border);
color: var(--cron-text);
}
.task-table tbody tr:last-child td {
border-bottom: 0;
}
.name-col { min-width: 220px; }
.name-col {
min-width: 220px;
}
.task-name, .task-text { color: var(--cron-text); font-size: 14px; line-height: 1.5; }
.task-name { font-weight: 600; }
.task-name,
.task-text {
color: var(--cron-text);
font-size: 14px;
line-height: 1.5;
}
.task-name {
font-weight: 600;
}
.task-subline { margin-top: 6px; color: var(--cron-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.task-subline {
margin-top: 6px;
color: var(--cron-muted);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.task-session { max-width: 340px; color: var(--cron-text); font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
.task-session {
max-width: 340px;
color: var(--cron-text);
font-size: 14px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.actions-col { width: 170px; }
.actions-col {
width: 170px;
}
.table-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 140px; }
.table-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 140px;
}
.state-panel {
display: flex;
@@ -499,20 +819,50 @@ onMounted(() => { loadJobs(); loadPlatforms() })
margin: 16px;
}
.cron-dialog-card { border: 1px solid var(--cron-border); border-radius: 18px; }
.cron-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.cron-form-grid--single { grid-template-columns: 1fr; }
@media (max-width: 1280px) { .cron-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 900px) {
.cron-topbar { flex-direction: column; align-items: flex-start; padding: 16px; }
.topbar-right { width: 100%; }
.topbar-desc { max-width: none; }
.cron-page { gap: 12px; padding: 12px; }
.table-actions { justify-content: flex-start; }
.cron-dialog-card {
border: 1px solid var(--cron-border);
border-radius: 18px;
}
@media (max-width: 640px) { .cron-overview-grid { grid-template-columns: 1fr; } }
.cron-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.cron-form-grid--single {
grid-template-columns: 1fr;
}
@media (max-width: 1280px) {
.cron-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.cron-topbar {
flex-direction: column;
align-items: flex-start;
padding: 16px;
}
.topbar-right {
width: 100%;
}
.topbar-desc {
max-width: none;
}
.cron-page {
gap: 12px;
padding: 12px;
}
.table-actions {
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.cron-overview-grid {
grid-template-columns: 1fr;
}
}
</style>
+3 -1
View File
@@ -168,7 +168,9 @@ const {
<v-window-item value="components">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<h2 class="text-h2 mb-0">{{ tm("tabs.handlersOperation") }}</h2>
<h2 class="text-h2 mb-0">
{{ tm("tabs.handlersOperation") }}
</h2>
</div>
</div>
<v-card
+21 -5
View File
@@ -861,7 +861,11 @@ export default {
flex-shrink: 0;
}
.topbar-left { display: flex; flex-direction: column; gap: 4px; }
.topbar-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.topbar-title {
font-size: clamp(24px, 3vw, 32px);
@@ -876,7 +880,11 @@ export default {
color: var(--platform-muted);
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.platform-content {
background: var(--platform-panel-bg);
@@ -899,8 +907,16 @@ export default {
}
@media (max-width: 900px) {
.platform-topbar { flex-direction: column; align-items: flex-start; }
.topbar-right { width: 100%; }
.platform-page { gap: 12px; padding: 12px; }
.platform-topbar {
flex-direction: column;
align-items: flex-start;
}
.topbar-right {
width: 100%;
}
.platform-page {
gap: 12px;
padding: 12px;
}
}
</style>
+432 -250
View File
@@ -5,31 +5,50 @@
<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">
<v-icon color="black" class="me-2">mdi-creation</v-icon>{{ tm('title') }}
<v-icon color="black" class="me-2">mdi-creation</v-icon
>{{ tm("title") }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
{{ tm('subtitle') }}
{{ tm("subtitle") }}
</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">
{{ tm('providers.addProvider') }}
<v-btn
color="primary"
prepend-icon="mdi-plus"
variant="tonal"
@click="showAddProviderDialog = true"
rounded="xl"
size="x-large"
>
{{ 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-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-icon start>{{ type.icon }}</v-icon>
{{ type.label }}
</v-tab>
</v-tabs>
<!-- Chat Completion: 左侧列表 + 右侧上下卡片布局 -->
<div v-if="selectedProviderType === 'chat_completion'" class="provider-workbench">
<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
@@ -46,13 +65,23 @@
</v-col>
<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">
<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-kicker">
{{ tm("providers.settings") }}
</div>
<div class="provider-config-title">
{{ selectedProviderSource.id }}
</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
{{ selectedProviderSource.api_base || "N/A" }}
</div>
</div>
@@ -65,7 +94,7 @@
@click="saveProviderSource"
variant="tonal"
>
{{ tm('providerSources.save') }}
{{ tm("providerSources.save") }}
</v-btn>
</div>
</div>
@@ -74,15 +103,27 @@
<template v-if="selectedProviderSource">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
<div class="provider-section-title">
{{ tm("providers.settings") }}
</div>
</div>
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema"
metadataKey="provider" :is-editing="true" />
<AstrBotConfig
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<section v-if="advancedSourceConfig" class="provider-section">
<section
v-if="advancedSourceConfig"
class="provider-section"
>
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
<div class="provider-section-title">
{{ tm("providerSources.advancedConfig") }}
</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
@@ -116,8 +157,10 @@
</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>
<v-icon size="48" color="grey-lighten-1"
>mdi-cursor-default-click</v-icon
>
<p class="mt-2">{{ tm("providerSources.selectHint") }}</p>
</div>
</v-card-text>
</v-card>
@@ -134,35 +177,77 @@
</v-col>
</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">
<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"
>
<template #item-details="{ item }">
<!-- 测试状态 chip -->
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
<v-tooltip
v-if="getProviderStatus(item.id)"
location="top"
max-width="300"
>
<template v-slot:activator="{ props }">
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
<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>
</template>
<span v-if="getProviderStatus(item.id).status === 'unavailable'">
<span
v-if="getProviderStatus(item.id).status === 'unavailable'"
>
{{ getProviderStatus(item.id).error }}
</span>
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
<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)">
{{ tm('availability.test') }}
<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>
</item-card>
@@ -173,20 +258,38 @@
</v-container>
<!-- 添加提供商对话框 -->
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="configSchema"
@select-template="selectProviderTemplate" />
<AddNewProvider
v-model:show="showAddProviderDialog"
:metadata="configSchema"
@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>
<v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn>
<v-btn variant="text" @click="showManualModelDialog = false"
>取消</v-btn
>
<v-btn color="primary" @click="confirmManualModel">添加</v-btn>
</v-card-actions>
</v-card>
@@ -195,21 +298,36 @@
<!-- 配置对话框 -->
<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">
{{ tm('dialogs.config.cancel') }}
<v-btn
variant="text"
@click="showProviderCfg = false"
:disabled="loading"
>
{{ tm("dialogs.config.cancel") }}
</v-btn>
<v-btn color="primary" @click="newProvider" :loading="loading">
{{ tm('dialogs.config.save') }}
{{ tm("dialogs.config.save") }}
</v-btn>
</v-card-actions>
</v-card>
@@ -219,25 +337,46 @@
<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" />
<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" @click="showProviderEditDialog = false"
:disabled="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.cancel') }}
<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
color="primary"
@click="saveEditedProvider"
:loading="savingProviders.includes(providerEditData?.id)"
>
{{ 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">
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
location="top"
>
{{ snackbar.message }}
</v-snackbar>
@@ -259,8 +398,15 @@
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="showAgentRunnerDialog = false">好的</v-btn>
<v-btn color="primary" variant="flat" @click="goToConfigPage">点击前往</v-btn>
<v-btn
color="grey"
variant="text"
@click="showAgentRunnerDialog = false"
>好的</v-btn
>
<v-btn color="primary" variant="flat" @click="goToConfigPage"
>点击前往</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
@@ -268,36 +414,36 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import axios from '@/utils/request'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
import ItemCard from '@/components/shared/ItemCard.vue'
import AddNewProvider from '@/components/provider/AddNewProvider.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 { ref, watch } from "vue";
import { useRouter } from "vue-router";
import axios from "@/utils/request";
import { useModuleI18n } from "@/i18n/composables";
import AstrBotConfig from "@/components/shared/AstrBotConfig.vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import AddNewProvider from "@/components/provider/AddNewProvider.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";
const props = defineProps({
defaultTab: {
type: String,
default: 'chat_completion'
}
})
default: "chat_completion",
},
});
const { tm } = useModuleI18n('features/provider')
const router = useRouter()
const { tm } = useModuleI18n("features/provider");
const router = useRouter();
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
message: "",
color: "success",
});
function showMessage(message, color = 'success') {
snackbar.value = { show: true, message, color }
function showMessage(message, color = "success") {
snackbar.value = { show: true, message, color };
}
const {
@@ -341,337 +487,373 @@ const {
} = useProviderSources({
defaultTab: props.defaultTab,
tm,
showMessage
})
showMessage,
});
// 非 chat 类型的状态
const showAddProviderDialog = ref(false)
const showProviderCfg = ref(false)
const newSelectedProviderName = ref('')
const newSelectedProviderConfig = ref({})
const newProviderOriginalId = ref('')
const updatingMode = ref(false)
const loading = ref(false)
const providerStatuses = ref([])
const showAgentRunnerDialog = ref(false)
const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const showManualModelDialog = ref(false)
const showAddProviderDialog = ref(false);
const showProviderCfg = ref(false);
const newSelectedProviderName = ref("");
const newSelectedProviderConfig = ref({});
const newProviderOriginalId = ref("");
const updatingMode = ref(false);
const loading = ref(false);
const providerStatuses = ref([]);
const showAgentRunnerDialog = ref(false);
const showProviderEditDialog = ref(false);
const providerEditData = ref(null);
const providerEditOriginalId = ref("");
const showManualModelDialog = ref(false);
const savingProviders = ref([])
const savingProviders = ref([]);
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
providerEditOriginalId.value = provider.id
showProviderEditDialog.value = true
providerEditData.value = JSON.parse(JSON.stringify(provider));
providerEditOriginalId.value = provider.id;
showProviderEditDialog.value = true;
}
function openManualModelDialog() {
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
showMessage(tm("providerSources.selectHint"), "error");
return;
}
manualModelId.value = ''
showManualModelDialog.value = true
manualModelId.value = "";
showManualModelDialog.value = true;
}
async function confirmManualModel() {
const modelId = manualModelId.value.trim()
const modelId = manualModelId.value.trim();
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
showMessage(tm("providerSources.selectHint"), "error");
return;
}
if (!modelId) {
showMessage(tm('models.manualModelRequired'), 'error')
return
showMessage(tm("models.manualModelRequired"), "error");
return;
}
if (modelAlreadyConfigured(modelId)) {
showMessage(tm('models.manualModelExists'), 'error')
return
showMessage(tm("models.manualModelExists"), "error");
return;
}
await addModelProvider(modelId)
showManualModelDialog.value = false
await addModelProvider(modelId);
showManualModelDialog.value = false;
}
watch(() => props.defaultTab, (val) => {
updateDefaultTab(val)
})
watch(
() => props.defaultTab,
(val) => {
updateDefaultTab(val);
},
);
// ===== 非 chat 类型的方法 =====
function getEmptyText() {
return tm('providers.empty.typed', { type: selectedProviderType.value })
return tm("providers.empty.typed", { type: selectedProviderType.value });
}
function selectProviderTemplate(name) {
newSelectedProviderName.value = name
newProviderOriginalId.value = ''
showProviderCfg.value = true
updatingMode.value = false
newSelectedProviderConfig.value = JSON.parse(JSON.stringify(
configSchema.value.provider.config_template[name] || {}
))
newSelectedProviderName.value = name;
newProviderOriginalId.value = "";
showProviderCfg.value = true;
updatingMode.value = false;
newSelectedProviderConfig.value = JSON.parse(
JSON.stringify(configSchema.value.provider.config_template[name] || {}),
);
}
function configExistingProvider(provider) {
newSelectedProviderName.value = provider.id
newProviderOriginalId.value = provider.id
newSelectedProviderConfig.value = {}
newSelectedProviderName.value = provider.id;
newProviderOriginalId.value = provider.id;
newSelectedProviderConfig.value = {};
// 比对默认配置模版,看看是否有更新
let templates = configSchema.value.provider.config_template || {}
let defaultConfig = {}
for (let key in templates) {
const templates = configSchema.value.provider.config_template || {};
let defaultConfig = {};
for (const key in templates) {
if (templates[key]?.type === provider.type) {
defaultConfig = templates[key]
break
defaultConfig = templates[key];
break;
}
}
const mergeConfigWithOrder = (target, source, reference) => {
if (source && typeof source === 'object' && !Array.isArray(source)) {
for (let key in source) {
if (source && typeof source === "object" && !Array.isArray(source)) {
for (const key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = Array.isArray(source[key]) ? [...source[key]] : { ...source[key] }
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = Array.isArray(source[key])
? [...source[key]]
: { ...source[key] };
} else {
target[key] = source[key]
target[key] = source[key];
}
}
}
}
for (let key in reference) {
if (typeof reference[key] === 'object' && reference[key] !== null) {
for (const key in reference) {
if (typeof reference[key] === "object" && reference[key] !== null) {
if (!(key in target)) {
if (Array.isArray(reference[key])) {
target[key] = [...reference[key]]
target[key] = [...reference[key]];
} else {
target[key] = {}
target[key] = {};
}
}
if (!Array.isArray(reference[key])) {
mergeConfigWithOrder(
target[key],
source && source[key] ? source[key] : {},
reference[key]
)
reference[key],
);
}
} else if (!(key in target)) {
target[key] = reference[key]
target[key] = reference[key];
}
}
}
};
if (defaultConfig) {
mergeConfigWithOrder(newSelectedProviderConfig.value, provider, defaultConfig)
mergeConfigWithOrder(
newSelectedProviderConfig.value,
provider,
defaultConfig,
);
}
showProviderCfg.value = true
updatingMode.value = true
showProviderCfg.value = true;
updatingMode.value = true;
}
async function newProvider() {
loading.value = true
const wasUpdating = updatingMode.value
loading.value = true;
const wasUpdating = updatingMode.value;
try {
if (wasUpdating) {
const res = await axios.post('/api/config/provider/update', {
const res = await axios.post("/api/config/provider/update", {
id: newProviderOriginalId.value || newSelectedProviderName.value,
config: newSelectedProviderConfig.value
})
if (res.data.status === 'error') {
showMessage(res.data.message || "更新失败!", 'error')
return
config: newSelectedProviderConfig.value,
});
if (res.data.status === "error") {
showMessage(res.data.message || "更新失败!", "error");
return;
}
showMessage(res.data.message || "更新成功!")
showMessage(res.data.message || "更新成功!");
if (wasUpdating) {
updatingMode.value = false
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')
return
const res = await axios.post(
"/api/config/provider/new",
newSelectedProviderConfig.value,
);
if (res.data.status === "error") {
showMessage(res.data.message || "添加失败!", "error");
return;
}
showMessage(res.data.message || "添加成功!")
showMessage(res.data.message || "添加成功!");
}
showProviderCfg.value = false
showProviderCfg.value = false;
} catch (err) {
showMessage(err.response?.data?.message || err.message, 'error')
showMessage(err.response?.data?.message || err.message, "error");
} finally {
loading.value = false
await loadConfig()
loading.value = false;
await loadConfig();
}
}
async function saveEditedProvider() {
if (!providerEditData.value) return
if (!providerEditData.value) return;
savingProviders.value.push(providerEditData.value.id)
savingProviders.value.push(providerEditData.value.id);
try {
const res = await axios.post('/api/config/provider/update', {
const res = await axios.post("/api/config/provider/update", {
id: providerEditOriginalId.value || providerEditData.value.id,
config: providerEditData.value
})
config: providerEditData.value,
});
if (res.data.status === 'error') {
throw new Error(res.data.message)
if (res.data.status === "error") {
throw new Error(res.data.message);
}
showMessage(res.data.message || tm('providerSources.saveSuccess'))
showProviderEditDialog.value = false
await loadConfig()
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')
showMessage(
err.response?.data?.message ||
err.message ||
tm("providerSources.saveError"),
"error",
);
} finally {
savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id)
savingProviders.value = savingProviders.value.filter(
(id) => id !== providerEditData.value?.id,
);
}
}
async function copyProvider(providerToCopy) {
const newProviderConfig = JSON.parse(JSON.stringify(providerToCopy))
const newProviderConfig = JSON.parse(JSON.stringify(providerToCopy));
const generateUniqueId = (baseId) => {
let newId = `${baseId}_copy`
let counter = 1
const existingIds = providers.value.map(p => p.id)
let newId = `${baseId}_copy`;
let counter = 1;
const existingIds = providers.value.map((p) => p.id);
while (existingIds.includes(newId)) {
newId = `${baseId}_copy_${counter}`
counter++
newId = `${baseId}_copy_${counter}`;
counter++;
}
return newId
}
newProviderConfig.id = generateUniqueId(providerToCopy.id)
newProviderConfig.enable = false
return newId;
};
newProviderConfig.id = generateUniqueId(providerToCopy.id);
newProviderConfig.enable = false;
loading.value = true
loading.value = true;
try {
const res = await axios.post('/api/config/provider/new', newProviderConfig)
showMessage(res.data.message || `成功复制并创建了 ${newProviderConfig.id}`)
await loadConfig()
const res = await axios.post("/api/config/provider/new", newProviderConfig);
showMessage(res.data.message || `成功复制并创建了 ${newProviderConfig.id}`);
await loadConfig();
} catch (err) {
showMessage(err.response?.data?.message || err.message, 'error')
showMessage(err.response?.data?.message || err.message, "error");
} finally {
loading.value = false
loading.value = false;
}
}
async function toggleProviderEnable(provider, value) {
provider.enable = value
provider.enable = value;
try {
const res = await axios.post('/api/config/provider/update', {
const res = await axios.post("/api/config/provider/update", {
id: provider.id,
config: provider
})
config: provider,
});
if (res.data.status === 'error') {
throw new Error(res.data.message)
if (res.data.status === "error") {
throw new Error(res.data.message);
}
showMessage(res.data.message || tm('messages.success.statusUpdate'))
showMessage(res.data.message || tm("messages.success.statusUpdate"));
} catch (error) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
showMessage(
error.response?.data?.message ||
error.message ||
tm("providerSources.saveError"),
"error",
);
} finally {
await loadConfig()
await loadConfig();
}
}
function isProviderTesting(providerId) {
return testingProviders.value.includes(providerId)
return testingProviders.value.includes(providerId);
}
function getProviderStatus(providerId) {
return providerStatuses.value.find(s => s.id === providerId)
return providerStatuses.value.find((s) => s.id === providerId);
}
async function testSingleProvider(provider) {
if (isProviderTesting(provider.id)) return
if (isProviderTesting(provider.id)) return;
testingProviders.value.push(provider.id)
testingProviders.value.push(provider.id);
const statusIndex = providerStatuses.value.findIndex(s => s.id === provider.id)
const statusIndex = providerStatuses.value.findIndex(
(s) => s.id === provider.id,
);
const pendingStatus = {
id: provider.id,
name: provider.id,
status: 'pending',
error: null
}
status: "pending",
error: null,
};
if (statusIndex !== -1) {
providerStatuses.value.splice(statusIndex, 1, pendingStatus)
providerStatuses.value.splice(statusIndex, 1, pendingStatus);
} else {
providerStatuses.value.unshift(pendingStatus)
providerStatuses.value.unshift(pendingStatus);
}
try {
if (!provider.enable) {
throw new Error('该提供商未被用户启用')
throw new Error("该提供商未被用户启用");
}
if (provider.provider_type === 'agent_runner') {
showAgentRunnerDialog.value = true
providerStatuses.value = providerStatuses.value.filter(s => s.id !== provider.id)
return
if (provider.provider_type === "agent_runner") {
showAgentRunnerDialog.value = true;
providerStatuses.value = providerStatuses.value.filter(
(s) => s.id !== provider.id,
);
return;
}
const startTime = performance.now()
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`)
if (res.data && res.data.status === 'ok') {
const index = providerStatuses.value.findIndex(s => s.id === provider.id)
const startTime = performance.now();
const res = await axios.get(
`/api/config/provider/check_one?id=${provider.id}`,
);
if (res.data && res.data.status === "ok") {
const index = providerStatuses.value.findIndex(
(s) => s.id === provider.id,
);
if (index !== -1) {
providerStatuses.value.splice(index, 1, res.data.data)
providerStatuses.value.splice(index, 1, res.data.data);
}
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
const latency = Math.max(0, Math.round(performance.now() - startTime));
showMessage(
tm("models.testSuccessWithLatency", { id: provider.id, latency }),
);
} else {
throw new Error(res.data?.message || `Failed to check status for ${provider.id}`)
throw new Error(
res.data?.message || `Failed to check status for ${provider.id}`,
);
}
} catch (err) {
const errorMessage = err.response?.data?.message || err.message || 'Unknown error'
const index = providerStatuses.value.findIndex(s => s.id === provider.id)
const errorMessage =
err.response?.data?.message || err.message || "Unknown error";
const index = providerStatuses.value.findIndex((s) => s.id === provider.id);
const failedStatus = {
id: provider.id,
name: provider.id,
status: 'unavailable',
error: errorMessage
}
status: "unavailable",
error: errorMessage,
};
if (index !== -1) {
providerStatuses.value.splice(index, 1, failedStatus)
providerStatuses.value.splice(index, 1, failedStatus);
}
} finally {
const index = testingProviders.value.indexOf(provider.id)
const index = testingProviders.value.indexOf(provider.id);
if (index > -1) {
testingProviders.value.splice(index, 1)
testingProviders.value.splice(index, 1);
}
}
}
function getStatusColor(status) {
switch (status) {
case 'available':
return 'success'
case 'unavailable':
return 'error'
case 'pending':
return 'grey'
case "available":
return "success";
case "unavailable":
return "error";
case "pending":
return "grey";
default:
return 'default'
return "default";
}
}
function getStatusText(status) {
const messages = {
available: tm('availability.available'),
unavailable: tm('availability.unavailable'),
pending: tm('availability.pending')
}
return messages[status] || status
available: tm("availability.available"),
unavailable: tm("availability.unavailable"),
pending: tm("availability.pending"),
};
return messages[status] || status;
}
function goToConfigPage() {
router.push('/config')
showAgentRunnerDialog.value = false
router.push("/config");
showAgentRunnerDialog.value = false;
}
</script>
<style scoped>
+56 -34
View File
@@ -51,25 +51,22 @@
>
<v-btn value="light" size="small">
<v-icon class="mr-1" size="18">mdi-white-balance-sunny</v-icon>
{{ tm("theme.customize.light")}}
{{ tm("theme.customize.light") }}
</v-btn>
<v-btn value="dark" size="small">
<v-icon class="mr-1" size="18">mdi-moon-waning-crescent</v-icon>
{{ tm("theme.customize.dark")}}
{{ tm("theme.customize.dark") }}
</v-btn>
<v-btn value="auto" size="small">
<v-icon class="mr-1" size="18">mdi-sync</v-icon>
{{ tm("theme.customize.auto")}}
{{ tm("theme.customize.auto") }}
</v-btn>
</v-btn-toggle>
<v-tooltip location="top">
<template #activator="{ props }">
<v-icon
v-bind="props"
size="16"
color="primary"
class="ml-1"
>mdi-help-circle-outline</v-icon>
<v-icon v-bind="props" size="16" color="primary" class="ml-1"
>mdi-help-circle-outline</v-icon
>
</template>
<span>{{ tm("theme.customize.autoSwitchDesc") }}</span>
</v-tooltip>
@@ -135,7 +132,9 @@
<div class="d-flex flex-wrap ga-3 mb-4">
<div>
<div class="text-body-2 mb-1">{{ tm("system.backup.title") }}</div>
<div class="text-caption text-medium-emphasis mb-2">{{ tm("system.backup.subtitle") }}</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ tm("system.backup.subtitle") }}
</div>
<v-btn color="primary" size="small" @click="openBackupDialog">
<v-icon class="mr-1" size="16">mdi-backup-restore</v-icon>
{{ tm("system.backup.button") }}
@@ -144,7 +143,9 @@
<v-divider vertical class="mx-2 mx-md-4" />
<div>
<div class="text-body-2 mb-1">{{ tm("system.restart.title") }}</div>
<div class="text-caption text-medium-emphasis mb-2">{{ tm("system.restart.subtitle") }}</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ tm("system.restart.subtitle") }}
</div>
<v-btn color="error" size="small" @click="restartAstrBot">
<v-icon class="mr-1" size="16">mdi-restart</v-icon>
{{ tm("system.restart.button") }}
@@ -152,9 +153,18 @@
</div>
<v-divider vertical class="mx-2 mx-md-4" />
<div>
<div class="text-body-2 mb-1">{{ tm("system.migration.title") }}</div>
<div class="text-caption text-medium-emphasis mb-2">{{ tm("system.migration.subtitle") }}</div>
<v-btn color="primary" size="small" variant="outlined" @click="startMigration">
<div class="text-body-2 mb-1">
{{ tm("system.migration.title") }}
</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ tm("system.migration.subtitle") }}
</div>
<v-btn
color="primary"
size="small"
variant="outlined"
@click="startMigration"
>
<v-icon class="mr-1" size="16">mdi-database-import</v-icon>
{{ tm("system.migration.button") }}
</v-btn>
@@ -280,12 +290,16 @@
<tbody>
<tr v-for="item in apiKeys" :key="item.key_id">
<td>{{ item.name }}</td>
<td><code>{{ item.key_prefix }}</code></td>
<td>
<code>{{ item.key_prefix }}</code>
</td>
<td>{{ (item.scopes || []).join(", ") }}</td>
<td>
<v-chip
size="small"
:color="item.is_revoked || item.is_expired ? 'error' : 'success'"
:color="
item.is_revoked || item.is_expired ? 'error' : 'success'
"
variant="tonal"
>
{{
@@ -349,16 +363,20 @@ import { restartAstrBot as restartAstrBotRuntime } from "@/utils/restartAstrBot"
import { useModuleI18n } from "@/i18n/composables";
import { useTheme } from "vuetify";
import { BlueBusinessLightTheme } from "@/theme/BlueBusinessLightTheme";
import { LIGHT_THEME_NAME, DARK_THEME_NAME, ThemeMode } from "@/theme/constants";
import {
LIGHT_THEME_NAME,
DARK_THEME_NAME,
ThemeMode,
} from "@/theme/constants";
import { useToastStore } from "@/stores/toast";
import { useCustomizerStore } from "@/stores/customizer";
import {
ApiKey,
ApiKeyActionResponse,
ApiKeyCreatePayload,
ApiKeyCreateResponse,
ApiKeyExpiresDays,
ApiKeyListResponse
import {
ApiKey,
ApiKeyActionResponse,
ApiKeyCreatePayload,
ApiKeyCreateResponse,
ApiKeyExpiresDays,
ApiKeyListResponse,
} from "@/types/api";
const { tm } = useModuleI18n("features/settings");
@@ -475,7 +493,10 @@ const applyThemePreset = (presetName: string) => {
// Apply to themes
applyThemeColors(preset.primary, preset.secondary);
toastStore.add({ message: tm("theme.customize.presetApplied") || "主题已应用", color: "success" });
toastStore.add({
message: tm("theme.customize.presetApplied") || "主题已应用",
color: "success",
});
};
const resolveThemes = () => {
@@ -689,10 +710,9 @@ const createApiKey = async () => {
const revokeApiKey = async (keyId: string) => {
try {
const res = await axios.post<ApiKeyActionResponse>(
"/api/apikey/revoke",
{ key_id: keyId },
);
const res = await axios.post<ApiKeyActionResponse>("/api/apikey/revoke", {
key_id: keyId,
});
if (res.data.status !== "ok") {
showToast(
res.data.message || tm("apiKey.messages.revokeFailed"),
@@ -717,10 +737,9 @@ const revokeApiKey = async (keyId: string) => {
const deleteApiKey = async (keyId: string) => {
try {
const res = await axios.post<ApiKeyActionResponse>(
"/api/apikey/delete",
{ key_id: keyId },
);
const res = await axios.post<ApiKeyActionResponse>("/api/apikey/delete", {
key_id: keyId,
});
if (res.data.status !== "ok") {
showToast(
res.data.message || tm("apiKey.messages.deleteFailed"),
@@ -753,7 +772,10 @@ const restartAstrBot = async () => {
"error",
);
} else {
console.error("An unexpected error occurred while restarting AstrBot:", error);
console.error(
"An unexpected error occurred while restarting AstrBot:",
error,
);
showToast(tm("apiKey.messages.restartFailed"), "error");
}
}
+531 -186
View File
@@ -2,33 +2,47 @@
<div class="subagent-page">
<div class="subagent-topbar">
<div class="topbar-left">
<div class="topbar-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="topbar-title">{{ tm('page.title') }}</h1>
<div class="topbar-eyebrow">{{ tm("header.eyebrow") }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px">
<h1 class="topbar-title">{{ tm("page.title") }}</h1>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>
{{ tm('page.beta') }}
{{ tm("page.beta") }}
</v-chip>
</div>
<p class="topbar-desc">{{ tm('page.subtitle') }}</p>
<p class="topbar-desc">{{ tm("page.subtitle") }}</p>
</div>
<div class="topbar-right">
<v-btn variant="text" color="primary" prepend-icon="mdi-refresh" :loading="loading" @click="reload">
{{ tm('actions.refresh') }}
<v-btn
variant="text"
color="primary"
prepend-icon="mdi-refresh"
:loading="loading"
@click="reload"
>
{{ tm("actions.refresh") }}
</v-btn>
<v-btn variant="tonal" color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">
{{ tm('actions.save') }}
<v-btn
variant="tonal"
color="primary"
prepend-icon="mdi-content-save"
:loading="saving"
@click="save"
>
{{ tm("actions.save") }}
</v-btn>
</div>
</div>
<div v-if="hasUnsavedChanges" class="unsaved-banner">
<v-icon size="18" color="warning">mdi-alert-circle-outline</v-icon>
<span>{{ tm('messages.unsavedChangesNotice') }}</span>
<span>{{ tm("messages.unsavedChangesNotice") }}</span>
</div>
<div class="subagent-section-head">
<div>
<div class="subagent-section-title">{{ tm('section.globalSettings') }}</div>
<div class="subagent-section-title">
{{ tm("section.globalSettings") }}
</div>
<div class="subagent-section-subtitle">{{ mainStateDescription }}</div>
</div>
</div>
@@ -37,105 +51,205 @@
<div class="subagent-setting-card">
<div class="setting-card-head">
<div>
<div class="setting-title">{{ tm('switches.enable') }}</div>
<div class="setting-subtitle">{{ tm('switches.enableHint') }}</div>
<div class="setting-title">{{ tm("switches.enable") }}</div>
<div class="setting-subtitle">{{ tm("switches.enableHint") }}</div>
</div>
<v-switch v-model="cfg.main_enable" color="primary" hide-details inset density="comfortable" />
<v-switch
v-model="cfg.main_enable"
color="primary"
hide-details
inset
density="comfortable"
/>
</div>
</div>
<div class="subagent-setting-card">
<div class="setting-card-head">
<div>
<div class="setting-title">{{ tm('switches.dedupe') }}</div>
<div class="setting-subtitle">{{ tm('switches.dedupeHint') }}</div>
<div class="setting-title">{{ tm("switches.dedupe") }}</div>
<div class="setting-subtitle">{{ tm("switches.dedupeHint") }}</div>
</div>
<v-switch v-model="cfg.remove_main_duplicate_tools" :disabled="!cfg.main_enable" color="primary" hide-details inset density="comfortable" />
<v-switch
v-model="cfg.remove_main_duplicate_tools"
:disabled="!cfg.main_enable"
color="primary"
hide-details
inset
density="comfortable"
/>
</div>
</div>
</div>
<div class="subagent-section-head">
<div>
<div class="subagent-section-title">{{ tm('section.title') }}</div>
<div class="subagent-section-subtitle">{{ tm('section.subtitle') }}</div>
<div class="subagent-section-title">{{ tm("section.title") }}</div>
<div class="subagent-section-subtitle">
{{ tm("section.subtitle") }}
</div>
</div>
<div class="subagent-section-actions">
<div class="subagent-pill">
<v-icon size="16">mdi-robot-outline</v-icon>
<span>{{ cfg.agents.length }}</span>
</div>
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="addAgent">
{{ tm('actions.add') }}
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-plus"
@click="addAgent"
>
{{ tm("actions.add") }}
</v-btn>
</div>
</div>
<div v-if="cfg.agents.length === 0" class="subagent-content subagent-empty-card">
<div
v-if="cfg.agents.length === 0"
class="subagent-content subagent-empty-card"
>
<div class="empty-wrap">
<v-icon icon="mdi-robot-off" size="60" class="mb-4" />
<div class="empty-title">{{ tm('empty.title') }}</div>
<div class="empty-subtitle mb-4">{{ tm('empty.subtitle') }}</div>
<div class="empty-title">{{ tm("empty.title") }}</div>
<div class="empty-subtitle mb-4">{{ tm("empty.subtitle") }}</div>
<v-btn color="primary" variant="tonal" @click="addAgent">
{{ tm('empty.action') }}
{{ tm("empty.action") }}
</v-btn>
</div>
</div>
<div v-else class="subagent-list">
<section v-for="(agent, idx) in cfg.agents" :key="agent.__key" class="subagent-content agent-panel">
<section
v-for="(agent, idx) in cfg.agents"
:key="agent.__key"
class="subagent-content agent-panel"
>
<div class="agent-summary">
<div class="agent-summary-main">
<div class="agent-summary-top">
<v-badge dot :color="agent.enabled ? 'success' : 'grey'" inline />
<span class="agent-name">{{ agent.name || tm('cards.unnamed') }}</span>
<v-chip size="x-small" variant="tonal" :color="agent.enabled ? 'success' : 'default'">
{{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }}
<span class="agent-name">{{
agent.name || tm("cards.unnamed")
}}</span>
<v-chip
size="x-small"
variant="tonal"
:color="agent.enabled ? 'success' : 'default'"
>
{{
agent.enabled
? tm("cards.statusEnabled")
: tm("cards.statusDisabled")
}}
</v-chip>
</div>
<div class="agent-summary-desc">
{{ agent.public_description || tm('cards.noDescription') }}
{{ agent.public_description || tm("cards.noDescription") }}
</div>
</div>
<div class="agent-summary-actions">
<v-btn :append-icon="isAgentExpanded(agent.__key) ? 'mdi-chevron-up' : 'mdi-chevron-down'" variant="text" color="default" density="comfortable" @click="toggleAgentExpanded(agent.__key)">
{{ isAgentExpanded(agent.__key) ? tm('actions.collapse') : tm('actions.expand') }}
<v-btn
:append-icon="
isAgentExpanded(agent.__key)
? 'mdi-chevron-up'
: 'mdi-chevron-down'
"
variant="text"
color="default"
density="comfortable"
@click="toggleAgentExpanded(agent.__key)"
>
{{
isAgentExpanded(agent.__key)
? tm("actions.collapse")
: tm("actions.expand")
}}
</v-btn>
<v-switch v-model="agent.enabled" color="success" hide-details inset density="compact" />
<v-btn icon="mdi-delete-outline" variant="text" color="error" density="comfortable" @click="removeAgent(idx)" />
<v-switch
v-model="agent.enabled"
color="success"
hide-details
inset
density="compact"
/>
<v-btn
icon="mdi-delete-outline"
variant="text"
color="error"
density="comfortable"
@click="removeAgent(idx)"
/>
</div>
</div>
<v-expand-transition>
<div v-show="isAgentExpanded(agent.__key)" class="agent-edit-grid">
<section class="inner-card">
<div class="subagent-section-title section-mini-title">{{ tm('section.agentSetup') }}</div>
<div class="subagent-section-title section-mini-title">
{{ tm("section.agentSetup") }}
</div>
<div class="subagent-form-grid subagent-form-grid--single">
<v-text-field v-model="agent.name" :label="tm('form.nameLabel')" :rules="[v => !!v || tm('messages.nameRequired'), v => /^[a-z][a-z0-9_]*$/.test(v) || tm('messages.namePattern')]" variant="outlined" density="comfortable" hide-details="auto" />
<v-text-field
v-model="agent.name"
:label="tm('form.nameLabel')"
:rules="[
(v) => !!v || tm('messages.nameRequired'),
(v) =>
/^[a-z][a-z0-9_]*$/.test(v) || tm('messages.namePattern'),
]"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<div class="selector-wrap">
<div class="selector-label">{{ tm('form.providerLabel') }}</div>
<div class="selector-label">
{{ tm("form.providerLabel") }}
</div>
<div class="selector-card">
<ProviderSelector v-model="agent.provider_id" provider-type="chat_completion" variant="outlined" density="comfortable" clearable />
<ProviderSelector
v-model="agent.provider_id"
provider-type="chat_completion"
variant="outlined"
density="comfortable"
clearable
/>
</div>
</div>
<div class="selector-wrap">
<div class="selector-label">{{ tm('form.personaLabel') }}</div>
<div class="selector-label">
{{ tm("form.personaLabel") }}
</div>
<div class="selector-card">
<PersonaSelector v-model="agent.persona_id" />
</div>
</div>
<v-textarea v-model="agent.public_description" :label="tm('form.descriptionLabel')" variant="outlined" density="comfortable" auto-grow hide-details="auto" />
<v-textarea
v-model="agent.public_description"
:label="tm('form.descriptionLabel')"
variant="outlined"
density="comfortable"
auto-grow
hide-details="auto"
/>
</div>
</section>
<section class="inner-card">
<div class="subagent-section-title section-mini-title">{{ tm('cards.personaPreview') }}</div>
<div class="subagent-section-subtitle">{{ tm('cards.previewHint') }}</div>
<div class="subagent-section-title section-mini-title">
{{ tm("cards.personaPreview") }}
</div>
<div class="subagent-section-subtitle">
{{ tm("cards.previewHint") }}
</div>
<div class="persona-preview-wrap">
<PersonaQuickPreview :model-value="agent.persona_id" class="h-100" />
<PersonaQuickPreview
:model-value="agent.persona_id"
class="h-100"
/>
</div>
</section>
</div>
@@ -143,175 +257,254 @@
</section>
</div>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000" location="top">
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
timeout="3000"
location="top"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">{{ tm('actions.close') }}</v-btn>
<v-btn variant="text" @click="snackbar.show = false">{{
tm("actions.close")
}}</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import axios from '@/utils/request'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useTheme } from 'vuetify'
import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue'
import PersonaSelector from '@/components/shared/PersonaSelector.vue'
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
import { useModuleI18n } from '@/i18n/composables'
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
import axios from "@/utils/request";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { onBeforeRouteLeave } from "vue-router";
import { useTheme } from "vuetify";
import PersonaQuickPreview from "@/components/shared/PersonaQuickPreview.vue";
import PersonaSelector from "@/components/shared/PersonaSelector.vue";
import ProviderSelector from "@/components/shared/ProviderSelector.vue";
import { useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
type SubAgentItem = {
__key: string
name: string
persona_id: string
public_description: string
enabled: boolean
provider_id?: string
}
__key: string;
name: string;
persona_id: string;
public_description: string;
enabled: boolean;
provider_id?: string;
};
type SubAgentConfig = {
main_enable: boolean
remove_main_duplicate_tools: boolean
agents: SubAgentItem[]
main_enable: boolean;
remove_main_duplicate_tools: boolean;
agents: SubAgentItem[];
};
const { tm } = useModuleI18n("features/subagent");
const theme = useTheme();
const confirmDialog = useConfirmDialog();
const loading = ref(false);
const saving = ref(false);
const isDark = computed(() => theme.global.current.value.dark);
const snackbar = ref({ show: false, message: "", color: "success" });
const expandedAgents = ref<Record<string, boolean>>({});
const initialSnapshot = ref("");
const hasLoaded = ref(false);
function toast(
message: string,
color: "success" | "error" | "warning" = "success",
) {
snackbar.value = { show: true, message, color };
}
const { tm } = useModuleI18n('features/subagent')
const theme = useTheme()
const confirmDialog = useConfirmDialog()
const cfg = ref<SubAgentConfig>({
main_enable: false,
remove_main_duplicate_tools: false,
agents: [],
});
const loading = ref(false)
const saving = ref(false)
const isDark = computed(() => theme.global.current.value.dark)
const snackbar = ref({ show: false, message: '', color: 'success' })
const expandedAgents = ref<Record<string, boolean>>({})
const initialSnapshot = ref('')
const hasLoaded = ref(false)
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
snackbar.value = { show: true, message, color }
}
const cfg = ref<SubAgentConfig>({ main_enable: false, remove_main_duplicate_tools: false, agents: [] })
const mainStateDescription = computed(() => cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled'))
const hasUnsavedChanges = computed(() => hasLoaded.value && serializeConfig(cfg.value) !== initialSnapshot.value)
const mainStateDescription = computed(() =>
cfg.value.main_enable
? tm("description.enabled")
: tm("description.disabled"),
);
const hasUnsavedChanges = computed(
() => hasLoaded.value && serializeConfig(cfg.value) !== initialSnapshot.value,
);
function normalizeConfig(raw: any): SubAgentConfig {
const main_enable = !!raw?.main_enable
const remove_main_duplicate_tools = !!raw?.remove_main_duplicate_tools
const agentsRaw = Array.isArray(raw?.agents) ? raw.agents : []
const main_enable = !!raw?.main_enable;
const remove_main_duplicate_tools = !!raw?.remove_main_duplicate_tools;
const agentsRaw = Array.isArray(raw?.agents) ? raw.agents : [];
const agents: SubAgentItem[] = agentsRaw.map((a: any, i: number) => ({
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
name: (a?.name ?? '').toString(),
persona_id: (a?.persona_id ?? '').toString(),
public_description: (a?.public_description ?? '').toString(),
name: (a?.name ?? "").toString(),
persona_id: (a?.persona_id ?? "").toString(),
public_description: (a?.public_description ?? "").toString(),
enabled: a?.enabled !== false,
provider_id: (a?.provider_id ?? undefined) as string | undefined
}))
return { main_enable, remove_main_duplicate_tools, agents }
provider_id: (a?.provider_id ?? undefined) as string | undefined,
}));
return { main_enable, remove_main_duplicate_tools, agents };
}
function serializeConfig(config: SubAgentConfig): string {
return JSON.stringify({
main_enable: config.main_enable,
remove_main_duplicate_tools: config.remove_main_duplicate_tools,
agents: config.agents.map(agent => ({
name: agent.name, persona_id: agent.persona_id, public_description: agent.public_description,
enabled: agent.enabled, provider_id: agent.provider_id ?? null
}))
})
agents: config.agents.map((agent) => ({
name: agent.name,
persona_id: agent.persona_id,
public_description: agent.public_description,
enabled: agent.enabled,
provider_id: agent.provider_id ?? null,
})),
});
}
async function loadConfig() {
loading.value = true
loading.value = true;
try {
const res = await axios.get('/api/subagent/config')
if (res.data.status === 'ok') {
cfg.value = normalizeConfig(res.data.data)
expandedAgents.value = Object.fromEntries(cfg.value.agents.map(agent => [agent.__key, false]))
initialSnapshot.value = serializeConfig(cfg.value)
hasLoaded.value = true
} else { toast(res.data.message || tm('messages.loadConfigFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.loadConfigFailed'), 'error') }
finally { loading.value = false }
const res = await axios.get("/api/subagent/config");
if (res.data.status === "ok") {
cfg.value = normalizeConfig(res.data.data);
expandedAgents.value = Object.fromEntries(
cfg.value.agents.map((agent) => [agent.__key, false]),
);
initialSnapshot.value = serializeConfig(cfg.value);
hasLoaded.value = true;
} else {
toast(res.data.message || tm("messages.loadConfigFailed"), "error");
}
} catch (e: any) {
toast(
e?.response?.data?.message || tm("messages.loadConfigFailed"),
"error",
);
} finally {
loading.value = false;
}
}
function addAgent() {
const key = `${Date.now()}_${Math.random().toString(16).slice(2)}`
cfg.value.agents.push({ __key: key, name: '', persona_id: '', public_description: '', enabled: true, provider_id: undefined })
expandedAgents.value[key] = false
const key = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
cfg.value.agents.push({
__key: key,
name: "",
persona_id: "",
public_description: "",
enabled: true,
provider_id: undefined,
});
expandedAgents.value[key] = false;
}
function removeAgent(idx: number) {
const [removed] = cfg.value.agents.splice(idx, 1)
if (removed) { delete expandedAgents.value[removed.__key] }
const [removed] = cfg.value.agents.splice(idx, 1);
if (removed) {
delete expandedAgents.value[removed.__key];
}
}
function isAgentExpanded(key: string): boolean { return expandedAgents.value[key] !== false }
function toggleAgentExpanded(key: string) { expandedAgents.value[key] = !isAgentExpanded(key) }
function isAgentExpanded(key: string): boolean {
return expandedAgents.value[key] !== false;
}
function toggleAgentExpanded(key: string) {
expandedAgents.value[key] = !isAgentExpanded(key);
}
function validateBeforeSave(): boolean {
const nameRe = /^[a-z][a-z0-9_]{0,63}$/
const seen = new Set<string>()
const nameRe = /^[a-z][a-z0-9_]{0,63}$/;
const seen = new Set<string>();
for (const agent of cfg.value.agents) {
const name = (agent.name || '').trim()
if (!name) { toast(tm('messages.nameMissing'), 'warning'); return false }
if (!nameRe.test(name)) { toast(tm('messages.nameInvalid'), 'warning'); return false }
if (seen.has(name)) { toast(tm('messages.nameDuplicate', { name }), 'warning'); return false }
seen.add(name)
if (!agent.persona_id) { toast(tm('messages.personaMissing', { name }), 'warning'); return false }
const name = (agent.name || "").trim();
if (!name) {
toast(tm("messages.nameMissing"), "warning");
return false;
}
if (!nameRe.test(name)) {
toast(tm("messages.nameInvalid"), "warning");
return false;
}
if (seen.has(name)) {
toast(tm("messages.nameDuplicate", { name }), "warning");
return false;
}
seen.add(name);
if (!agent.persona_id) {
toast(tm("messages.personaMissing", { name }), "warning");
return false;
}
}
return true
return true;
}
async function save() {
if (!validateBeforeSave()) return
saving.value = true
if (!validateBeforeSave()) return;
saving.value = true;
try {
const payload = {
main_enable: cfg.value.main_enable,
remove_main_duplicate_tools: cfg.value.remove_main_duplicate_tools,
agents: cfg.value.agents.map(agent => ({
name: agent.name, persona_id: agent.persona_id, public_description: agent.public_description,
enabled: agent.enabled, provider_id: agent.provider_id
}))
agents: cfg.value.agents.map((agent) => ({
name: agent.name,
persona_id: agent.persona_id,
public_description: agent.public_description,
enabled: agent.enabled,
provider_id: agent.provider_id,
})),
};
const res = await axios.post("/api/subagent/config", payload);
if (res.data.status === "ok") {
initialSnapshot.value = serializeConfig(cfg.value);
hasLoaded.value = true;
toast(res.data.message || tm("messages.saveSuccess"), "success");
} else {
toast(res.data.message || tm("messages.saveFailed"), "error");
}
const res = await axios.post('/api/subagent/config', payload)
if (res.data.status === 'ok') {
initialSnapshot.value = serializeConfig(cfg.value)
hasLoaded.value = true
toast(res.data.message || tm('messages.saveSuccess'), 'success')
} else { toast(res.data.message || tm('messages.saveFailed'), 'error') }
} catch (e: any) { toast(e?.response?.data?.message || tm('messages.saveFailed'), 'error') }
finally { saving.value = false }
} catch (e: any) {
toast(e?.response?.data?.message || tm("messages.saveFailed"), "error");
} finally {
saving.value = false;
}
}
async function reload() {
if (hasUnsavedChanges.value) {
const confirmed = await askForConfirmation(tm('messages.unsavedChangesReloadConfirm'), confirmDialog)
if (!confirmed) return
const confirmed = await askForConfirmation(
tm("messages.unsavedChangesReloadConfirm"),
confirmDialog,
);
if (!confirmed) return;
}
await loadConfig()
await loadConfig();
}
async function confirmLeaveIfNeeded(): Promise<boolean> {
if (!hasUnsavedChanges.value) return true
return askForConfirmation(tm('messages.unsavedChangesLeaveConfirm'), confirmDialog)
if (!hasUnsavedChanges.value) return true;
return askForConfirmation(
tm("messages.unsavedChangesLeaveConfirm"),
confirmDialog,
);
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedChanges.value) return
event.preventDefault()
event.returnValue = ''
if (!hasUnsavedChanges.value) return;
event.preventDefault();
event.returnValue = "";
}
onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload); reload() })
onBeforeUnmount(() => { window.removeEventListener('beforeunload', handleBeforeUnload) })
onBeforeRouteLeave(async () => { return await confirmLeaveIfNeeded() })
onMounted(() => {
window.addEventListener("beforeunload", handleBeforeUnload);
reload();
});
onBeforeUnmount(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
onBeforeRouteLeave(async () => {
return await confirmLeaveIfNeeded();
});
</script>
<style scoped>
@@ -371,7 +564,11 @@ onBeforeRouteLeave(async () => { return await confirmLeaveIfNeeded() })
flex-shrink: 0;
}
.topbar-left { display: flex; flex-direction: column; gap: 4px; }
.topbar-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.topbar-eyebrow {
margin-bottom: 4px;
@@ -398,7 +595,11 @@ onBeforeRouteLeave(async () => { return await confirmLeaveIfNeeded() })
max-width: 60ch;
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.unsaved-banner {
display: flex;
@@ -412,13 +613,31 @@ onBeforeRouteLeave(async () => { return await confirmLeaveIfNeeded() })
font-size: 13px;
}
.subagent-section-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; }
.subagent-section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
}
.subagent-section-title { font-size: 19px; font-weight: 650; letter-spacing: -0.02em; color: var(--subagent-text); }
.subagent-section-title {
font-size: 19px;
font-weight: 650;
letter-spacing: -0.02em;
color: var(--subagent-text);
}
.subagent-section-subtitle { margin-top: 6px; color: var(--subagent-muted); font-size: 13px; }
.subagent-section-subtitle {
margin-top: 6px;
color: var(--subagent-muted);
font-size: 13px;
}
.subagent-section-actions { display: flex; align-items: center; gap: 10px; }
.subagent-section-actions {
display: flex;
align-items: center;
gap: 10px;
}
.subagent-pill {
display: inline-flex;
@@ -432,7 +651,11 @@ onBeforeRouteLeave(async () => { return await confirmLeaveIfNeeded() })
font-size: 13px;
}
.subagent-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.subagent-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.subagent-setting-card {
padding: 18px;
@@ -442,11 +665,24 @@ onBeforeRouteLeave(async () => { return await confirmLeaveIfNeeded() })
backdrop-filter: blur(16px);
}
.setting-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.setting-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.setting-title { font-size: 15px; font-weight: 600; color: var(--subagent-text); }
.setting-title {
font-size: 15px;
font-weight: 600;
color: var(--subagent-text);
}
.setting-subtitle { margin-top: 6px; color: var(--subagent-muted); font-size: 13px; }
.setting-subtitle {
margin-top: 6px;
color: var(--subagent-muted);
font-size: 13px;
}
.subagent-content {
background: var(--subagent-panel-bg);
@@ -455,55 +691,164 @@ onBeforeRouteLeave(async () => { return await confirmLeaveIfNeeded() })
backdrop-filter: blur(16px);
}
.subagent-empty-card { min-height: 280px; padding: 22px; }
.subagent-empty-card {
min-height: 280px;
padding: 22px;
}
.empty-wrap { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: var(--subagent-muted); }
.empty-wrap {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--subagent-muted);
}
.empty-title { font-size: 20px; font-weight: 650; color: var(--subagent-text); margin-bottom: 8px; }
.empty-title {
font-size: 20px;
font-weight: 650;
color: var(--subagent-text);
margin-bottom: 8px;
}
.empty-subtitle { color: var(--subagent-muted); font-size: 14px; }
.empty-subtitle {
color: var(--subagent-muted);
font-size: 14px;
}
.subagent-list { display: grid; gap: 16px; }
.subagent-list {
display: grid;
gap: 16px;
}
.agent-panel { padding: 22px; display: grid; gap: 18px; }
.agent-panel {
padding: 22px;
display: grid;
gap: 18px;
}
.agent-summary { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; width: 100%; }
.agent-summary {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
width: 100%;
}
.agent-summary-main { min-width: 0; flex: 1; }
.agent-summary-main {
min-width: 0;
flex: 1;
}
.agent-summary-top { display: flex; align-items: center; gap: 10px; min-width: 0; }
.agent-summary-top {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.agent-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; font-weight: 650; color: var(--subagent-text); }
.agent-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 650;
color: var(--subagent-text);
}
.agent-summary-desc { margin-top: 8px; color: var(--subagent-muted); font-size: 13px; }
.agent-summary-desc {
margin-top: 8px;
color: var(--subagent-muted);
font-size: 13px;
}
.agent-summary-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.agent-summary-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.agent-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.agent-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.inner-card { padding: 18px; background: var(--subagent-card-bg); border: 1px solid var(--subagent-border); border-radius: 12px; min-width: 0; }
.inner-card {
padding: 18px;
background: var(--subagent-card-bg);
border: 1px solid var(--subagent-border);
border-radius: 12px;
min-width: 0;
}
.section-mini-title { margin-bottom: 10px; }
.section-mini-title {
margin-bottom: 10px;
}
.subagent-form-grid { display: grid; gap: 16px; }
.subagent-form-grid--single { grid-template-columns: 1fr; }
.subagent-form-grid {
display: grid;
gap: 16px;
}
.subagent-form-grid--single {
grid-template-columns: 1fr;
}
.selector-wrap { display: grid; gap: 8px; }
.selector-wrap {
display: grid;
gap: 8px;
}
.selector-label { color: var(--subagent-muted); font-size: 13px; font-weight: 500; }
.selector-label {
color: var(--subagent-muted);
font-size: 13px;
font-weight: 500;
}
.selector-card { border: 1px solid var(--subagent-border); border-radius: 12px; padding: 14px; background: transparent; }
.selector-card {
border: 1px solid var(--subagent-border);
border-radius: 12px;
padding: 14px;
background: transparent;
}
.persona-preview-wrap { min-height: 320px; }
.persona-preview-wrap {
min-height: 320px;
}
@media (max-width: 1280px) { .subagent-settings-grid { grid-template-columns: 1fr; } }
@media (max-width: 1280px) {
.subagent-settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1080px) { .agent-edit-grid { grid-template-columns: 1fr; } }
@media (max-width: 1080px) {
.agent-edit-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.subagent-topbar { flex-direction: column; align-items: flex-start; padding: 16px; }
.topbar-right { width: 100%; }
.subagent-page { gap: 12px; padding: 12px; }
.setting-card-head, .agent-summary { flex-direction: column; align-items: flex-start; }
.subagent-topbar {
flex-direction: column;
align-items: flex-start;
padding: 16px;
}
.topbar-right {
width: 100%;
}
.subagent-page {
gap: 12px;
padding: 12px;
}
.setting-card-head,
.agent-summary {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+2 -1
View File
@@ -712,7 +712,8 @@ watch(showProviderDialog, async (visible, wasVisible) => {
}
@keyframes energyPulse {
0%, 100% {
0%,
100% {
opacity: 0.6;
box-shadow: 0 0 6px rgba(0, 242, 255, 0.4);
}
@@ -1,23 +1,26 @@
<script setup lang="ts">
import AuthLogin from '../authForms/AuthLogin.vue';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import { onMounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
import AuthLogin from "../authForms/AuthLogin.vue";
import LanguageSwitcher from "@/components/shared/LanguageSwitcher.vue";
import { onMounted, ref } from "vue";
import { useAuthStore } from "@/stores/auth";
import { useRouter } from "vue-router";
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { useModuleI18n } from "@/i18n/composables";
import { useTheme } from "vuetify";
const cardVisible = ref(false);
const router = useRouter();
const authStore = useAuthStore();
const customizer = useCustomizerStore();
const { tm: t } = useModuleI18n('features/auth');
const { tm: t } = useModuleI18n("features/auth");
const theme = useTheme();
// 主题切换函数
function toggleTheme() {
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
const newTheme =
customizer.uiTheme === "PurpleThemeDark"
? "PurpleTheme"
: "PurpleThemeDark";
customizer.SET_UI_THEME(newTheme);
theme.global.name.value = newTheme;
}
@@ -27,9 +30,9 @@ onMounted(async () => {
if (authStore.has_token()) {
const onboardingCompleted = await authStore.checkOnboardingCompleted();
if (onboardingCompleted) {
router.push('/dashboard/default');
router.push("/dashboard/default");
} else {
router.push('/welcome');
router.push("/welcome");
}
return;
}
@@ -46,23 +49,43 @@ onMounted(async () => {
<v-card class="login-card" elevation="1">
<v-card-title>
<div class="d-flex justify-space-between align-center w-100">
<img width="80" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<img
width="80"
src="@/assets/images/icon-no-shadow.svg"
alt="AstrBot Logo"
/>
<div class="d-flex align-center gap-1">
<LanguageSwitcher />
<v-divider vertical class="mx-1"
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(var(--v-theme-primary), 0.45) !important;"></v-divider>
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
<v-divider
vertical
class="mx-1"
style="
height: 24px !important;
opacity: 0.9 !important;
align-self: center !important;
border-color: rgba(var(--v-theme-primary), 0.45) !important;
"
></v-divider>
<v-btn
@click="toggleTheme"
class="theme-toggle-btn"
icon
variant="text"
size="small"
>
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
mdi-white-balance-sunny
</v-icon>
<v-tooltip activator="parent" location="top">
{{ t('theme.switchToLight') }}
{{ t("theme.switchToLight") }}
</v-tooltip>
</v-btn>
</div>
</div>
<div class="ml-2" style="font-size: 26px;">{{ t('logo.title') }}</div>
<div class="mt-2 ml-2" style="font-size: 14px; color: grey;">{{ t('logo.subtitle') }}</div>
<div class="ml-2" style="font-size: 26px">{{ t("logo.title") }}</div>
<div class="mt-2 ml-2" style="font-size: 14px; color: grey">
{{ t("logo.subtitle") }}
</div>
</v-card-title>
<v-card-text>
<AuthLogin />
@@ -110,7 +110,7 @@ export default {
}
.stat-card::before {
content: '';
content: "";
position: absolute;
inset: 0;
background-image:
@@ -122,7 +122,7 @@ export default {
}
.stat-card::after {
content: '';
content: "";
position: absolute;
top: 0;
left: 12px;
@@ -77,7 +77,7 @@ export default {
/* Grid overlay — lattice constraint texture */
.stat-card::before {
content: '';
content: "";
position: absolute;
inset: 0;
background-image:
@@ -90,7 +90,7 @@ export default {
/* Bright seam: magnetic containment at top edge only */
.stat-card::after {
content: '';
content: "";
position: absolute;
top: 0;
left: 12px;
@@ -123,7 +123,9 @@ export default {
}
.uptime-card:hover {
box-shadow: inset 0 2px 12px rgba(0, 0, 0, 0.35), 0 0 16px rgba(76, 175, 80, 0.35) !important;
box-shadow:
inset 0 2px 12px rgba(0, 0, 0, 0.35),
0 0 16px rgba(76, 175, 80, 0.35) !important;
}
.memory-card {
@@ -132,11 +134,13 @@ export default {
}
.memory-card:hover {
box-shadow: inset 0 2px 12px rgba(0, 0, 0, 0.35), 0 0 16px rgba(255, 152, 0, 0.35) !important;
box-shadow:
inset 0 2px 12px rgba(0, 0, 0, 0.35),
0 0 16px rgba(255, 152, 0, 0.35) !important;
}
.stat-card::before {
content: '';
content: "";
position: absolute;
inset: 0;
background-image:
@@ -148,7 +152,7 @@ export default {
}
.stat-card::after {
content: '';
content: "";
position: absolute;
top: 0;
left: 12px;
@@ -76,7 +76,7 @@ export default {
}
.stat-card::before {
content: '';
content: "";
position: absolute;
inset: 0;
background-image:
@@ -88,7 +88,7 @@ export default {
}
.stat-card::after {
content: '';
content: "";
position: absolute;
top: 0;
left: 12px;
File diff suppressed because it is too large Load Diff
+395 -196
View File
@@ -3,64 +3,113 @@
<!-- 页面标题 -->
<div class="page-header">
<div>
<h1 class="text-h4 mb-2">{{ t('list.title') }}</h1>
<p class="text-subtitle-1 text-medium-emphasis">{{ t('list.subtitle') }}</p>
<h1 class="text-h4 mb-2">{{ t("list.title") }}</h1>
<p class="text-subtitle-1 text-medium-emphasis">
{{ t("list.subtitle") }}
</p>
</div>
<v-btn icon="mdi-information-outline" variant="text" size="small" color="grey"
href="https://astrbot.app/use/knowledge-base.html" target="_blank" />
<v-btn
icon="mdi-information-outline"
variant="text"
size="small"
color="grey"
href="https://astrbot.app/use/knowledge-base.html"
target="_blank"
/>
</div>
<!-- 操作按钮栏 -->
<div class="action-bar mb-6">
<v-btn prepend-icon="mdi-plus" color="primary" variant="elevated" @click="showCreateDialog = true">
{{ t('list.create') }}
<v-btn
prepend-icon="mdi-plus"
color="primary"
variant="elevated"
@click="showCreateDialog = true"
>
{{ t("list.create") }}
</v-btn>
<v-btn prepend-icon="mdi-refresh" variant="tonal" @click="loadKnowledgeBases" :loading="loading">
{{ t('list.refresh') }}
<v-btn
prepend-icon="mdi-refresh"
variant="tonal"
@click="loadKnowledgeBases"
:loading="loading"
>
{{ t("list.refresh") }}
</v-btn>
</div>
<!-- 知识库网格 -->
<div v-if="loading && kbList.length === 0" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-medium-emphasis">{{ t('list.loading') }}</p>
<p class="mt-4 text-medium-emphasis">{{ t("list.loading") }}</p>
</div>
<div v-else-if="kbList.length > 0" class="kb-grid">
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" :hover="!kb.init_error"
<v-card
v-for="kb in kbList"
:key="kb.kb_id"
class="kb-card"
elevation="2"
:hover="!kb.init_error"
:class="{ 'kb-card-error': kb.init_error }"
@click="!kb.init_error && navigateToDetail(kb.kb_id)">
@click="!kb.init_error && navigateToDetail(kb.kb_id)"
>
<!-- Error badge -->
<v-badge v-if="kb.init_error" color="error" icon="mdi-alert-circle"
class="kb-error-badge position-absolute" style="top: 0; right: 0; transform: translate(34%, -34%);" />
<div class="kb-card-content" :class="{ 'kb-card-content-error': kb.init_error }">
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
<v-badge
v-if="kb.init_error"
color="error"
icon="mdi-alert-circle"
class="kb-error-badge position-absolute"
style="top: 0; right: 0; transform: translate(34%, -34%)"
/>
<div
class="kb-card-content"
:class="{ 'kb-card-content-error': kb.init_error }"
>
<div class="kb-emoji">{{ kb.emoji || "📚" }}</div>
<h3 class="kb-name">{{ kb.kb_name }}</h3>
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">
{{ kb.description || "暂无描述" }}
</p>
<!-- Error message display -->
<div v-if="kb.init_error" class="kb-error-panel mt-3 mb-2">
<div class="kb-error-title">
<v-icon size="16" color="error">mdi-close-circle</v-icon>
<span>{{ t('list.initError') }}</span>
<span>{{ t("list.initError") }}</span>
</div>
<div class="kb-error-detail" :title="kb.init_error">
{{ kb.init_error }}
</div>
<div class="kb-error-detail" :title="kb.init_error">{{ kb.init_error }}</div>
</div>
<div class="kb-stats mt-4" v-if="!kb.init_error">
<div class="stat-item">
<v-icon size="small" color="primary">mdi-file-document</v-icon>
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
<span>{{ kb.doc_count || 0 }} {{ t("list.documents") }}</span>
</div>
<div class="stat-item">
<v-icon size="small" color="secondary">mdi-text-box</v-icon>
<span>{{ kb.chunk_count || 0 }} {{ t('list.chunks') }}</span>
<span>{{ kb.chunk_count || 0 }} {{ t("list.chunks") }}</span>
</div>
</div>
<div class="kb-actions" :class="{ 'error-actions': kb.init_error }">
<v-btn v-if="!kb.init_error" icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
<v-btn
v-if="!kb.init_error"
icon="mdi-pencil"
size="small"
variant="text"
color="info"
@click.stop="editKB(kb)"
/>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click.stop="confirmDelete(kb)"
/>
</div>
</div>
</v-card>
@@ -69,10 +118,16 @@
<!-- 空状态 -->
<div v-else class="empty-state">
<v-icon size="100" color="grey-lighten-2">mdi-book-open-variant</v-icon>
<h2 class="mt-4">{{ t('list.empty') }}</h2>
<v-btn class="mt-6" prepend-icon="mdi-plus" color="primary" variant="elevated" size="large"
@click="showCreateDialog = true">
{{ t('list.create') }}
<h2 class="mt-4">{{ t("list.empty") }}</h2>
<v-btn
class="mt-6"
prepend-icon="mdi-plus"
color="primary"
variant="elevated"
size="large"
@click="showCreateDialog = true"
>
{{ t("list.create") }}
</v-btn>
</div>
@@ -80,7 +135,9 @@
<v-dialog v-model="showCreateDialog" max-width="600px" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<span class="text-h5">{{ editingKB ? t('edit.title') : t('create.title') }}</span>
<span class="text-h5">{{
editingKB ? t("edit.title") : t("create.title")
}}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeCreateDialog" />
</v-card-title>
@@ -93,40 +150,74 @@
<div class="emoji-display" @click="showEmojiPicker = true">
{{ formData.emoji }}
</div>
<p class="text-caption text-medium-emphasis mt-2">{{ t('create.emojiLabel') }}</p>
<p class="text-caption text-medium-emphasis mt-2">
{{ t("create.emojiLabel") }}
</p>
</div>
<!-- 表单 -->
<v-form ref="formRef" @submit.prevent="submitForm">
<v-text-field v-model="formData.kb_name" :label="t('create.nameLabel')"
:placeholder="t('create.namePlaceholder')" variant="outlined"
:rules="[v => !!v || t('create.nameRequired')]" required class="mb-4" hint="后续如修改知识库名称,需重新在配置文件更新。" persistent-hint />
<v-text-field
v-model="formData.kb_name"
:label="t('create.nameLabel')"
:placeholder="t('create.namePlaceholder')"
variant="outlined"
:rules="[(v) => !!v || t('create.nameRequired')]"
required
class="mb-4"
hint="后续如修改知识库名称,需重新在配置文件更新。"
persistent-hint
/>
<v-textarea v-model="formData.description" :label="t('create.descriptionLabel')"
:placeholder="t('create.descriptionPlaceholder')" variant="outlined" rows="3" class="mb-4" />
<v-textarea
v-model="formData.description"
:label="t('create.descriptionLabel')"
:placeholder="t('create.descriptionPlaceholder')"
variant="outlined"
rows="3"
class="mb-4"
/>
<v-select v-model="formData.embedding_provider_id" :items="embeddingProviders"
:item-title="item => item.embedding_model || item.id" :item-value="'id'"
:label="t('create.embeddingModelLabel')" variant="outlined" class="mb-4" :disabled="editingKB !== null" hint="嵌入模型选择后无法修改,如需更换请创建新的知识库。" persistent-hint>
<v-select
v-model="formData.embedding_provider_id"
:items="embeddingProviders"
:item-title="(item) => item.embedding_model || item.id"
:item-value="'id'"
:label="t('create.embeddingModelLabel')"
variant="outlined"
class="mb-4"
:disabled="editingKB !== null"
hint="嵌入模型选择后无法修改,如需更换请创建新的知识库。"
persistent-hint
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #subtitle>
{{ t('create.providerInfo', {
id: item.raw.id,
dimensions: item.raw.embedding_dimensions || 'N/A'
}) }}
{{
t("create.providerInfo", {
id: item.raw.id,
dimensions: item.raw.embedding_dimensions || "N/A",
})
}}
</template>
</v-list-item>
</template>
</v-select>
<v-select v-model="formData.rerank_provider_id" :items="rerankProviders"
:item-title="item => item.rerank_model || item.id" :item-value="'id'"
:label="t('create.rerankModelLabel')" variant="outlined" clearable class="mb-2">
<v-select
v-model="formData.rerank_provider_id"
:items="rerankProviders"
:item-title="(item) => item.rerank_model || item.id"
:item-value="'id'"
:label="t('create.rerankModelLabel')"
variant="outlined"
clearable
class="mb-2"
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #subtitle>
{{ t('create.rerankProviderInfo', { id: item.raw.id }) }}
{{ t("create.rerankProviderInfo", { id: item.raw.id }) }}
</template>
</v-list-item>
</template>
@@ -139,10 +230,15 @@
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeCreateDialog">
{{ t('create.cancel') }}
{{ t("create.cancel") }}
</v-btn>
<v-btn color="primary" variant="elevated" @click="submitForm" :loading="saving">
{{ editingKB ? t('edit.submit') : t('create.submit') }}
<v-btn
color="primary"
variant="elevated"
@click="submitForm"
:loading="saving"
>
{{ editingKB ? t("edit.submit") : t("create.submit") }}
</v-btn>
</v-card-actions>
</v-card>
@@ -151,13 +247,24 @@
<!-- Emoji 选择器对话框 -->
<v-dialog v-model="showEmojiPicker" max-width="500px">
<v-card>
<v-card-title class="pa-4">{{ t('emoji.title') }}</v-card-title>
<v-card-title class="pa-4">{{ t("emoji.title") }}</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<div v-for="category in emojiCategories" :key="category.key" class="mb-4">
<p class="text-subtitle-2 mb-2">{{ t(`emoji.categories.${category.key}`) }}</p>
<div
v-for="category in emojiCategories"
:key="category.key"
class="mb-4"
>
<p class="text-subtitle-2 mb-2">
{{ t(`emoji.categories.${category.key}`) }}
</p>
<div class="emoji-grid">
<div v-for="emoji in category.emojis" :key="emoji" class="emoji-item" @click="selectEmoji(emoji)">
<div
v-for="emoji in category.emojis"
:key="emoji"
class="emoji-item"
@click="selectEmoji(emoji)"
>
{{ emoji }}
</div>
</div>
@@ -167,7 +274,7 @@
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showEmojiPicker = false">
{{ t('emoji.close') }}
{{ t("emoji.close") }}
</v-btn>
</v-card-actions>
</v-card>
@@ -176,22 +283,31 @@
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px" persistent>
<v-card>
<v-card-title class="pa-4 text-h6">{{ t('delete.title') }}</v-card-title>
<v-card-title class="pa-4 text-h6">{{
t("delete.title")
}}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('delete.confirmText', { name: deleteTarget?.kb_name || '' }) }}</p>
<p>
{{ t("delete.confirmText", { name: deleteTarget?.kb_name || "" }) }}
</p>
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
{{ t('delete.warning') }}
{{ t("delete.warning") }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="cancelDelete">
{{ t('delete.cancel') }}
{{ t("delete.cancel") }}
</v-btn>
<v-btn color="error" variant="elevated" @click="deleteKB" :loading="deleting">
{{ t('delete.confirm') }}
<v-btn
color="error"
variant="elevated"
@click="deleteKB"
:loading="deleting"
>
{{ t("delete.confirm") }}
</v-btn>
</v-card-actions>
</v-card>
@@ -202,254 +318,337 @@
{{ snackbar.text }}
</v-snackbar>
<div class="position-absolute" style="bottom: 0px; right: 16px;">
<small @click="router.push('/alkaid/knowledge-base')"><a style="text-decoration: underline; cursor: pointer;">切换到旧版知识库</a></small>
<div class="position-absolute" style="bottom: 0px; right: 16px">
<small @click="router.push('/alkaid/knowledge-base')"
><a style="text-decoration: underline; cursor: pointer"
>切换到旧版知识库</a
></small
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from '@/utils/request'
import { useModuleI18n } from '@/i18n/composables'
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import axios from "@/utils/request";
import { useModuleI18n } from "@/i18n/composables";
const { tm: t } = useModuleI18n('features/knowledge-base/index')
const router = useRouter()
const { tm: t } = useModuleI18n("features/knowledge-base/index");
const router = useRouter();
// 状态
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const kbList = ref<any[]>([])
const embeddingProviders = ref<any[]>([])
const rerankProviders = ref<any[]>([])
const originalEmbeddingProvider = ref<string | null>(null)
const showEmbeddingWarning = ref(false)
const embeddingChangeDialog = ref(false)
const pendingEmbeddingProvider = ref<string | null>(null)
const loading = ref(false);
const saving = ref(false);
const deleting = ref(false);
const kbList = ref<any[]>([]);
const embeddingProviders = ref<any[]>([]);
const rerankProviders = ref<any[]>([]);
const originalEmbeddingProvider = ref<string | null>(null);
const showEmbeddingWarning = ref(false);
const embeddingChangeDialog = ref(false);
const pendingEmbeddingProvider = ref<string | null>(null);
// 对话框
const showCreateDialog = ref(false)
const showEmojiPicker = ref(false)
const showDeleteDialog = ref(false)
const showCreateDialog = ref(false);
const showEmojiPicker = ref(false);
const showDeleteDialog = ref(false);
// Snackbar 通知
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
text: "",
color: "success",
});
// 表单
const formRef = ref()
const editingKB = ref<any>(null)
const deleteTarget = ref<any>(null)
const formRef = ref();
const editingKB = ref<any>(null);
const deleteTarget = ref<any>(null);
const formData = ref({
kb_name: '',
description: '',
emoji: '📚',
kb_name: "",
description: "",
emoji: "📚",
embedding_provider_id: null,
rerank_provider_id: null
})
rerank_provider_id: null,
});
// Emoji 分类
const emojiCategories = [
{
key: 'books',
emojis: ['📚', '📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📑', '🗂️', '📂', '📁', '🗃️', '🗄️']
key: "books",
emojis: [
"📚",
"📖",
"📕",
"📗",
"📘",
"📙",
"📓",
"📔",
"📒",
"📑",
"🗂️",
"📂",
"📁",
"🗃️",
"🗄️",
],
},
{
key: 'emotions',
emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍']
key: "emotions",
emojis: [
"😀",
"😃",
"😄",
"😁",
"😆",
"😅",
"🤣",
"😂",
"🙂",
"🙃",
"😉",
"😊",
"😇",
"🥰",
"😍",
],
},
{
key: 'objects',
emojis: ['💡', '🔬', '🔭', '🗿', '🏆', '🎯', '🎓', '🔑', '🔒', '🔓', '🔔', '🔕', '🔨', '🛠️', '⚙️']
key: "objects",
emojis: [
"💡",
"🔬",
"🔭",
"🗿",
"🏆",
"🎯",
"🎓",
"🔑",
"🔒",
"🔓",
"🔔",
"🔕",
"🔨",
"🛠️",
"⚙️",
],
},
{
key: 'symbols',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '⭐', '🌟', '✨', '💫', '⚡', '🔥']
}
]
key: "symbols",
emojis: [
"❤️",
"🧡",
"💛",
"💚",
"💙",
"💜",
"🖤",
"🤍",
"🤎",
"⭐",
"🌟",
"✨",
"💫",
"⚡",
"🔥",
],
},
];
// 加载知识库列表
const loadKnowledgeBases = async (refreshStats = false) => {
loading.value = true
loading.value = true;
try {
const params: any = {}
const params: any = {};
if (refreshStats) {
params.refresh_stats = 'true'
params.refresh_stats = "true";
}
const response = await axios.get('/api/kb/list', { params })
if (response.data.status === 'ok') {
kbList.value = response.data.data.items || []
const response = await axios.get("/api/kb/list", { params });
if (response.data.status === "ok") {
kbList.value = response.data.data.items || [];
} else {
showSnackbar(response.data.message || t('messages.loadError'), 'error')
showSnackbar(response.data.message || t("messages.loadError"), "error");
}
} catch (error) {
console.error('Failed to load knowledge bases:', error)
showSnackbar(t('messages.loadError'), 'error')
console.error("Failed to load knowledge bases:", error);
showSnackbar(t("messages.loadError"), "error");
} finally {
loading.value = false
loading.value = false;
}
}
};
// 加载提供商配置
const loadProviders = async () => {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'embedding,rerank' }
})
if (response.data.status === 'ok') {
const response = await axios.get("/api/config/provider/list", {
params: { provider_type: "embedding,rerank" },
});
if (response.data.status === "ok") {
embeddingProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'embedding'
)
(p: any) => p.provider_type === "embedding",
);
rerankProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'rerank'
)
(p: any) => p.provider_type === "rerank",
);
}
} catch (error) {
console.error('Failed to load providers:', error)
console.error("Failed to load providers:", error);
}
}
};
// 导航到详情页
const navigateToDetail = (kbId: string) => {
router.push({ name: 'NativeKBDetail', params: { kbId } })
}
router.push({ name: "NativeKBDetail", params: { kbId } });
};
// 编辑知识库
const editKB = (kb: any) => {
editingKB.value = kb
originalEmbeddingProvider.value = kb.embedding_provider_id
editingKB.value = kb;
originalEmbeddingProvider.value = kb.embedding_provider_id;
formData.value = {
kb_name: kb.kb_name,
description: kb.description || '',
emoji: kb.emoji || '📚',
description: kb.description || "",
emoji: kb.emoji || "📚",
embedding_provider_id: kb.embedding_provider_id,
rerank_provider_id: kb.rerank_provider_id
}
showCreateDialog.value = true
}
rerank_provider_id: kb.rerank_provider_id,
};
showCreateDialog.value = true;
};
// 确认删除
const confirmDelete = (kb: any) => {
deleteTarget.value = kb
showDeleteDialog.value = true
}
deleteTarget.value = kb;
showDeleteDialog.value = true;
};
// 取消删除
const cancelDelete = () => {
showDeleteDialog.value = false
deleteTarget.value = null
}
showDeleteDialog.value = false;
deleteTarget.value = null;
};
// 删除知识库
const deleteKB = async () => {
if (!deleteTarget.value) return
if (!deleteTarget.value) return;
deleting.value = true
deleting.value = true;
try {
const response = await axios.post('/api/kb/delete', {
kb_id: deleteTarget.value.kb_id
})
const response = await axios.post("/api/kb/delete", {
kb_id: deleteTarget.value.kb_id,
});
console.log('Delete response:', response.data) // 调试日志
console.log("Delete response:", response.data); // 调试日志
if (response.data.status === 'ok') {
showSnackbar(t('messages.deleteSuccess'))
if (response.data.status === "ok") {
showSnackbar(t("messages.deleteSuccess"));
// 先刷新列表,再关闭对话框
await loadKnowledgeBases()
showDeleteDialog.value = false
deleteTarget.value = null
await loadKnowledgeBases();
showDeleteDialog.value = false;
deleteTarget.value = null;
} else {
showSnackbar(response.data.message || t('messages.deleteFailed'), 'error')
showSnackbar(
response.data.message || t("messages.deleteFailed"),
"error",
);
}
} catch (error) {
console.error('Failed to delete knowledge base:', error)
showSnackbar(t('messages.deleteFailed'), 'error')
console.error("Failed to delete knowledge base:", error);
showSnackbar(t("messages.deleteFailed"), "error");
} finally {
deleting.value = false
deleting.value = false;
}
}
};
// 提交表单
const submitForm = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
const { valid } = await formRef.value.validate();
if (!valid) return;
saving.value = true
saving.value = true;
try {
const payload = {
kb_name: formData.value.kb_name,
description: formData.value.description,
emoji: formData.value.emoji,
embedding_provider_id: formData.value.embedding_provider_id,
rerank_provider_id: formData.value.rerank_provider_id
}
rerank_provider_id: formData.value.rerank_provider_id,
};
let response
let response;
if (editingKB.value) {
response = await axios.post('/api/kb/update', {
response = await axios.post("/api/kb/update", {
kb_id: editingKB.value.kb_id,
...payload
})
...payload,
});
} else {
response = await axios.post('/api/kb/create', payload)
response = await axios.post("/api/kb/create", payload);
}
if (response.data.status === 'ok') {
showSnackbar(editingKB.value ? t('messages.updateSuccess') : t('messages.createSuccess'))
closeCreateDialog()
await loadKnowledgeBases()
if (response.data.status === "ok") {
showSnackbar(
editingKB.value
? t("messages.updateSuccess")
: t("messages.createSuccess"),
);
closeCreateDialog();
await loadKnowledgeBases();
} else {
showSnackbar(response.data.message || (editingKB.value ? t('messages.updateFailed') : t('messages.createFailed')), 'error')
showSnackbar(
response.data.message ||
(editingKB.value
? t("messages.updateFailed")
: t("messages.createFailed")),
"error",
);
}
} catch (error) {
console.error('Failed to save knowledge base:', error)
showSnackbar(editingKB.value ? t('messages.updateFailed') : t('messages.createFailed'), 'error')
console.error("Failed to save knowledge base:", error);
showSnackbar(
editingKB.value ? t("messages.updateFailed") : t("messages.createFailed"),
"error",
);
} finally {
saving.value = false
saving.value = false;
}
}
};
// 关闭创建对话框
const closeCreateDialog = () => {
showCreateDialog.value = false
editingKB.value = null
originalEmbeddingProvider.value = null
showEmbeddingWarning.value = false
pendingEmbeddingProvider.value = null
showCreateDialog.value = false;
editingKB.value = null;
originalEmbeddingProvider.value = null;
showEmbeddingWarning.value = false;
pendingEmbeddingProvider.value = null;
formData.value = {
kb_name: '',
description: '',
emoji: '📚',
kb_name: "",
description: "",
emoji: "📚",
embedding_provider_id: null,
rerank_provider_id: null
}
formRef.value?.reset()
}
rerank_provider_id: null,
};
formRef.value?.reset();
};
// 选择 emoji
const selectEmoji = (emoji: string) => {
formData.value.emoji = emoji
showEmojiPicker.value = false
}
formData.value.emoji = emoji;
showEmojiPicker.value = false;
};
// 显示通知
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
const showSnackbar = (text: string, color: string = "success") => {
snackbar.value.text = text;
snackbar.value.color = color;
snackbar.value.show = true;
};
onMounted(() => {
loadKnowledgeBases(true) // 首次加载时刷新统计信息
loadProviders()
})
loadKnowledgeBases(true); // 首次加载时刷新统计信息
loadProviders();
});
</script>
<style scoped>
+3 -3
View File
@@ -2,10 +2,10 @@
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"allowJs": true,
"types": ["node"]
"types": ["node"],
},
"include": ["vite.config.*", "scripts/**/*"]
"include": ["vite.config.*", "scripts/**/*"],
}