feat(prompts): add prompts loader and configuration management

- Implemented a new prompts loader in `loader.go` to manage prompt templates and configurations.
- Introduced caching mechanism for prompt configurations to optimize loading.
- Added functions to apply platform-specific prompt template overrides.
- Created unit tests in `loader_test.go` to validate prompt configuration loading and reloading behavior.
- Ensured that the last valid configuration is retained in case of errors during reload.
This commit is contained in:
2026-04-13 16:08:12 +08:00
parent 6066f43a7d
commit 1b01caac0f
21 changed files with 1630 additions and 392 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ReloadOutlined } from "@ant-design/icons-vue";
import { MinusCircleFilled, ReloadOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, notification } from "ant-design-vue";
import type {
@@ -86,7 +86,12 @@ watch(
}
selectionHydrated.value = false;
coverHydrated.value = false;
await refreshRuntime();
await Promise.allSettled([
refreshRuntime(),
props.articleId ? detailQuery.refetch() : Promise.resolve(),
accountsQuery.refetch(),
platformsQuery.refetch(),
]);
},
{ immediate: true },
);
@@ -96,7 +101,10 @@ watchEffect(() => {
return;
}
if (!coverHydrated.value && !detailQuery.isPending.value) {
if (!coverHydrated.value) {
if (detailQuery.isPending.value || detailQuery.isFetching.value) {
return;
}
const initialUrl = resolveApiURL(detailQuery.data.value?.cover_asset_url);
coverAssetUrl.value = initialUrl;
coverFileName.value = deriveCoverFileName(initialUrl);
@@ -110,8 +118,11 @@ watchEffect(() => {
if (
detailQuery.isPending.value ||
detailQuery.isFetching.value ||
accountsQuery.isPending.value ||
accountsQuery.isFetching.value ||
platformsQuery.isPending.value ||
platformsQuery.isFetching.value ||
runtimeLoading.value
) {
return;
@@ -475,35 +486,32 @@ function showPublishFailures(result: PublisherPublishResponse): void {
{{ coverRequired ? t("media.publish.messages.coverRequired") : t("media.publish.coverHint") }}
</p>
<div
v-if="effectiveCoverEnabled"
class="publish-modal__cover-body"
:class="{ 'publish-modal__cover-body--single': !coverAssetUrl }"
>
<button
type="button"
class="publish-modal__cover-preview"
@click="coverPickerOpen = true"
>
<template v-if="coverAssetUrl">
<img :src="coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="publish-modal__cover-plus">+</span>
<span>{{ t("media.publish.coverUpload") }}</span>
</template>
</button>
<div v-if="effectiveCoverEnabled" class="publish-modal__cover-body">
<div class="publish-modal__cover-preview-wrap">
<button
type="button"
class="publish-modal__cover-preview"
@click="coverPickerOpen = true"
>
<template v-if="coverAssetUrl">
<img :src="coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="publish-modal__cover-plus">+</span>
<span>{{ t("media.publish.coverUpload") }}</span>
</template>
</button>
<div v-if="coverAssetUrl" class="publish-modal__cover-side">
<div class="publish-modal__cover-actions">
<a-button @click="handleRemoveCover">
{{ t("article.editor.coverRemove") }}
</a-button>
</div>
<div class="publish-modal__cover-file">
{{ coverFileName || t("article.editor.coverSaved") }}
</div>
<button
v-if="coverAssetUrl"
type="button"
class="publish-modal__cover-remove"
:aria-label="t('article.editor.coverRemove')"
:title="t('article.editor.coverRemove')"
@click.stop="handleRemoveCover"
>
<MinusCircleFilled />
</button>
</div>
</div>
</section>
@@ -728,20 +736,21 @@ function showPublishFailures(result: PublisherPublishResponse): void {
}
.publish-modal__cover-body {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
gap: 16px;
display: flex;
margin-top: 14px;
}
.publish-modal__cover-body--single {
grid-template-columns: 176px;
.publish-modal__cover-preview-wrap {
position: relative;
width: 176px;
flex: 0 0 176px;
}
.publish-modal__cover-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 124px;
padding: 0;
border: 1px dashed #d3dceb;
@@ -774,22 +783,29 @@ function showPublishFailures(result: PublisherPublishResponse): void {
line-height: 1;
}
.publish-modal__cover-side {
.publish-modal__cover-remove {
position: absolute;
top: 8px;
right: 8px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.14);
color: #ff4d4f;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
}
.publish-modal__cover-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.publish-modal__cover-file {
color: #667085;
font-size: 13px;
line-height: 1.7;
.publish-modal__cover-remove:hover {
transform: scale(1.04);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
color: #ff7875;
}
@media (max-width: 860px) {
@@ -802,7 +818,7 @@ function showPublishFailures(result: PublisherPublishResponse): void {
}
.publish-modal__cover-body {
grid-template-columns: 1fr;
display: flex;
}
}
</style>
+1
View File
@@ -558,6 +558,7 @@ export const monitoringApi = {
keyword_id?: number | null;
days?: number;
business_date?: string;
ai_platform_id?: string;
}) {
return apiClient.get<MonitoringDashboardCompositeResponse>("/api/tenant/monitoring/dashboard/composite", {
params,
@@ -142,6 +142,7 @@ const saveMutation = useMutation({
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
queryClient.invalidateQueries({ queryKey: ["templates"] }),
queryClient.invalidateQueries({ queryKey: ["articles", "detail", articleId.value] }),
queryClient.invalidateQueries({ queryKey: ["articles", "detail", articleId.value, "publish-modal"] }),
]);
},
onError: (error) => {
@@ -29,6 +29,7 @@ const questionId = computed(() => parsePositiveNumber(route.params.questionId));
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash));
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text));
const keywordId = computed(() => normalizeQueryValue(route.query.keyword_id));
const requestedPlatformId = computed(() => normalizeQueryValue(route.query.ai_platform_id));
const businessDate = computed(() => normalizeTrackingBusinessDate(route.query.business_date) || trackingToday);
const dateFrom = computed(() => {
return normalizeQueryValue(route.query.date_from) || businessDate.value;
@@ -44,6 +45,7 @@ const detailQuery = useQuery({
brandId.value,
questionId.value,
questionHash.value,
requestedPlatformId.value,
dateFrom.value,
dateTo.value,
]),
@@ -53,6 +55,7 @@ const detailQuery = useQuery({
date_from: dateFrom.value,
date_to: dateTo.value,
question_hash: questionHash.value || undefined,
ai_platform_id: requestedPlatformId.value || undefined,
}),
});
@@ -66,9 +69,8 @@ watch(
return;
}
const requestedPlatformId = normalizeQueryValue(route.query.ai_platform_id);
const requestedPlatform = requestedPlatformId
? platforms.find((item) => item.ai_platform_id === requestedPlatformId)
const requestedPlatform = requestedPlatformId.value
? platforms.find((item) => item.ai_platform_id === requestedPlatformId.value)
: null;
const sampledPlatform = platforms.find((item) => item.sample_status === "sampled");
const nextPlatform = requestedPlatform ?? sampledPlatform ?? platforms[0];
@@ -130,6 +132,7 @@ function goBack(): void {
query: {
brand_id: brandId.value ? String(brandId.value) : undefined,
keyword_id: keywordId.value || undefined,
ai_platform_id: requestedPlatformId.value || undefined,
business_date: businessDate.value,
},
});
+54 -2
View File
@@ -72,11 +72,26 @@ const platformAppearanceMap: Record<string, { color: string; surface: string; gl
kimi: { color: "#111827", surface: "#f3f4f6", glyph: "K" },
};
const trackingPlatformOptions = [
{ label: "全部", value: "all" },
{ label: "DeepSeek", value: "deepseek" },
{ label: "千问", value: "qwen" },
{ label: "豆包", value: "doubao" },
] as const;
const trackingPlatformIds = new Set<string>(
trackingPlatformOptions
.map((item) => item.value)
.filter((value) => value !== "all"),
);
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
const pluginPreparing = ref(false);
selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value);
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
const brandsQuery = useQuery({
@@ -151,17 +166,28 @@ watch(
{ immediate: true },
);
watch([selectedBrandId, selectedKeywordId, selectedBusinessDate], ([brandId, keywordId, businessDate]) => {
watch(
() => route.query.ai_platform_id,
(value) => {
selectedPlatformId.value = normalizeTrackingPlatformId(value);
},
{ immediate: true },
);
watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate], ([brandId, keywordId, platformId, businessDate]) => {
const nextBrandId = brandId ? String(brandId) : undefined;
const nextKeywordId = keywordId ? String(keywordId) : undefined;
const nextPlatformId = platformId !== "all" ? platformId : undefined;
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday;
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined;
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined;
const currentPlatformId = normalizeTrackingPlatformId(route.query.ai_platform_id);
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
if (
currentBrandId === nextBrandId &&
currentKeywordId === nextKeywordId &&
currentPlatformId === (nextPlatformId ?? "all") &&
currentBusinessDate === nextBusinessDate
) {
return;
@@ -172,6 +198,7 @@ watch([selectedBrandId, selectedKeywordId, selectedBusinessDate], ([brandId, key
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
business_date: nextBusinessDate,
},
});
@@ -208,13 +235,21 @@ async function ensureMonitoringPluginReady(options?: { silent?: boolean }): Prom
}
const dashboardQuery = useQuery({
queryKey: computed(() => ["tracking", "dashboard", selectedBrandId.value, selectedKeywordId.value, selectedBusinessDate.value]),
queryKey: computed(() => [
"tracking",
"dashboard",
selectedBrandId.value,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
monitoringApi.dashboardComposite({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.value,
days: trackingMaxHistoryDays,
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
business_date: selectedBusinessDate.value,
}),
});
@@ -451,6 +486,7 @@ function openQuestion(question: MonitoringHotQuestion): void {
business_date: selectedBusinessDate.value,
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== "all" ? { ai_platform_id: selectedPlatformId.value } : {}),
...(selectedKeywordId.value ? { keyword_id: String(selectedKeywordId.value) } : {}),
},
});
@@ -554,6 +590,14 @@ function normalizeQueryValue(value: unknown): string {
return String(Array.isArray(value) ? value[0] ?? "" : value ?? "").trim();
}
function normalizeTrackingPlatformId(value: unknown): string {
const normalized = normalizeQueryValue(value).toLowerCase();
if (!normalized) {
return "all";
}
return trackingPlatformIds.has(normalized) ? normalized : "all";
}
function normalizeTrackingBusinessDate(value: unknown): string | null {
const normalized = normalizeQueryValue(value);
if (!normalized) {
@@ -588,6 +632,14 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
>
<template #actions>
<a-space wrap align="center" class="tracking-actions-wrap">
<div class="tracking-action-item">
<span class="tracking-action-label">平台</span>
<a-select
v-model:value="selectedPlatformId"
class="tracking-select"
:options="trackingPlatformOptions"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">品牌</span>
<a-select