mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
fix: 修复 Vue 样式穿透警告并更新 tsconfig 解析模式
This commit is contained in:
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) ===
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1453
-1165
File diff suppressed because it is too large
Load Diff
+525
-175
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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/**/*"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user