feat: v1.3.0 瞬间插件适配、提示词模块化重构、实时刷新、安全审核失败关闭及多项Bug修复

- 新增瞬间插件(Moments)评论区适配
- 重构提示词组装为模块化架构
- 强化身份约束:禁止编造事实、泄露系统信息
- 日志页面增加实时刷新功能
- AI安全审核改为失败关闭策略
- 评论人昵称广告判定
- 修复PromptBuilder安全提示词可被绕过
- 修复processFalsePositive无去重锁和失败卡在PENDING
- 修复AiReplyCleanupService删除处理中记录
- 修复hasExistingReply错误时静默放行
- 修复Endpoint参数校验/搜索大小写/批量并发限制
- 修复LogsView实时刷新漏检状态变化和竞态
- 修复ContextExtractor/ReplyReconciler空指针风险
- 优化实时刷新:间隔可配置/滚动保留/用户操作后重置计时
- UI标签Prompt设置改为提示词设置
This commit is contained in:
sunny-335
2026-07-02 12:39:35 +08:00
parent 615935d947
commit 34e2927021
21 changed files with 719 additions and 190 deletions
+7 -4
View File
@@ -238,10 +238,13 @@ const fetchHealth = async () => {
}
}
const refreshData = () => {
fetchStats()
fetchPersona()
Toast.success("数据已刷新")
const refreshData = async () => {
try {
await Promise.all([fetchStats(), fetchPersona()])
Toast.success("数据已刷新")
} catch (e) {
Toast.error("刷新失败")
}
}
const openSettings = () => {
+152 -16
View File
@@ -39,6 +39,19 @@
</select>
<input v-model="filterKeyword" type="text" placeholder="搜索回复内容..." class="filter-input" />
<button class="btn-reset" @click="resetFilters">重置</button>
<div class="autorefresh-group">
<button class="btn-autorefresh" :class="{ 'is-active': autoRefresh }" @click="toggleAutoRefresh" :title="autoRefresh ? '点击关闭实时刷新' : '点击开启实时刷新'">
<span class="autorefresh-dot" v-if="autoRefresh"></span>
{{ autoRefresh ? '实时刷新' : '实时刷新' }}
</button>
<select v-if="autoRefresh" v-model="autoRefreshSecs" class="autorefresh-interval" :title="`刷新间隔:${autoRefreshSecs}秒`">
<option :value="5">5s</option>
<option :value="10">10s</option>
<option :value="30">30s</option>
<option :value="60">60s</option>
</select>
<span v-if="autoRefresh" class="autorefresh-status">{{ lastRefreshLabel }}</span>
</div>
</div>
<!-- 列表区 -->
@@ -87,7 +100,7 @@
<div class="footer-info">
<span>评分: <strong>{{ reply.spec.score }}</strong></span>
<span v-if="reply.spec.postSlug">
关联: <a :href="getPostUrl(reply.spec.postSlug)" target="_blank" class="post-link">{{ reply.spec.postSlug }}</a>
关联: <a :href="getPostUrl(reply.spec.postSlug, reply.spec.postKind)" target="_blank" class="post-link">{{ reply.spec.postSlug }}</a>
</span>
<span v-if="reply.spec.retryCount > 0" class="retry-text">重试 {{ reply.spec.retryCount }} </span>
</div>
@@ -106,8 +119,8 @@
<div v-if="totalPages > 1" class="pagination">
<span> {{ total }} </span>
<div class="pagination-btns">
<VButton size="sm" :disabled="page <= 1" @click="page--">上一页</VButton>
<VButton size="sm" :disabled="page >= totalPages" @click="page++">下一页</VButton>
<VButton size="sm" :disabled="page <= 1 || loading" @click="page--">上一页</VButton>
<VButton size="sm" :disabled="page >= totalPages || loading" @click="page++">下一页</VButton>
</div>
</div>
</div>
@@ -151,7 +164,7 @@
<!-- 误报反馈确认弹窗 -->
<teleport to="body">
<div v-if="showFalsePositiveDialog" class="dialog-overlay" @click.self="showFalsePositiveDialog = false">
<div v-if="showFalsePositiveDialog" class="dialog-overlay" @click.self="closeFalsePositiveDialog">
<div class="dialog-box fp-dialog">
<div class="dialog-header">
<h3>确认为误报</h3>
@@ -167,7 +180,7 @@
<button class="fp-btn fp-btn-secondary" :disabled="fpLoading" @click="handleFalsePositive('approveOnly')">
仅通过
</button>
<button class="fp-btn fp-btn-ghost" :disabled="fpLoading" @click="showFalsePositiveDialog = false">
<button class="fp-btn fp-btn-ghost" :disabled="fpLoading" @click="closeFalsePositiveDialog">
取消
</button>
</div>
@@ -194,6 +207,93 @@ const showDialog = ref(false); const conversationLoading = ref(false); const con
const showFalsePositiveDialog = ref(false); const falsePositiveTarget = ref<AiCommentReplyItem | null>(null); const fpLoading = ref(false);
const triggerAiLoadingName = ref<string | null>(null);
// 实时刷新:定时轮询新数据。暂停条件:标签页隐藏、loading 中、弹窗打开。
// 优化:轻量变更检测、新记录提示、连续失败自动关闭、间隔可配置、用户操作后重置计时、保留滚动位置、显示相对更新时间
const autoRefresh = ref(false); const autoRefreshSecs = ref(10); let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
let consecutiveFailures = 0; const MAX_FAILURES = 5;
const lastRefreshTime = ref<number | null>(null); let refreshRelativeTimer: ReturnType<typeof setInterval> | null = null;
const lastRefreshLabel = ref("等待中…");
let autoRefreshing = false; // 防止 autoRefreshTick 与 fetchReplies 竞态
// 轻量签名:total + 首尾 name + 首尾状态,检测记录数量、顺序、状态变化
const dataSignature = (items: any[], totalCount: number) => {
if (!items.length) return `${totalCount}|`;
const first = items[0]; const last = items[items.length - 1];
const firstStatus = first.spec?.status || ""; const lastStatus = last.spec?.status || "";
const firstPublished = first.spec?.published || "";
const lastPublished = last.spec?.published || "";
return `${totalCount}|${first.metadata.name}|${firstStatus}|${firstPublished}|${last.metadata.name}|${lastStatus}|${lastPublished}`;
};
const updateRelativeTime = () => {
if (lastRefreshTime.value == null) { lastRefreshLabel.value = "等待中…"; return; }
const diff = Math.floor((Date.now() - lastRefreshTime.value) / 1000);
if (diff < 5) lastRefreshLabel.value = "刚刚更新";
else if (diff < 60) lastRefreshLabel.value = `${diff}秒前更新`;
else lastRefreshLabel.value = `${Math.floor(diff / 60)}分钟前更新`;
};
const isPageVisible = () => !document.hidden;
const autoRefreshTick = async () => {
// 标签页隐藏、正在加载、或存在打开的弹窗时不轮询,避免干扰用户操作
if (!autoRefresh.value || loading.value || autoRefreshing || showDialog.value || showFalsePositiveDialog.value) return;
autoRefreshing = true;
// 保留滚动位置:刷新前后记录并恢复 list-area 的 scrollTop
const listArea = document.querySelector(".list-area");
const savedScroll = listArea ? listArea.scrollTop : 0;
const prevSignature = dataSignature(replies.value, total.value);
const prevFirstPage = page.value === 1;
const prevCount = total.value;
try {
const params: any = { page: page.value, size: size.value }
if (filterStatus.value) params.status = filterStatus.value; if (filterSentiment.value) params.sentiment = filterSentiment.value; if (filterKeyword.value) params.keyword = filterKeyword.value;
const { data } = await axiosInstance.get("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies", { params })
const newItems = data.items || []; const newTotal = data.total || 0;
const newSignature = dataSignature(newItems, newTotal);
consecutiveFailures = 0; // 成功,重置失败计数
lastRefreshTime.value = Date.now();
updateRelativeTime();
// 仅当数据签名变化时更新,减少不必要的渲染
if (newSignature !== prevSignature) {
// 在首页且有新增记录时提示用户(仅在首页轮询能可靠判定"新增")
if (prevFirstPage && newTotal > prevCount) {
Toast.success(`发现 ${newTotal - prevCount} 条新记录`);
}
replies.value = newItems; total.value = newTotal; totalPages.value = Math.ceil(newTotal / size.value);
// 数据删除导致当前页变空时,回退到上一页
if (replies.value.length === 0 && page.value > 1) { page.value--; }
}
// 恢复滚动位置
if (listArea) listArea.scrollTop = savedScroll;
} catch (e) {
consecutiveFailures++;
if (consecutiveFailures >= MAX_FAILURES) {
autoRefresh.value = false;
stopAutoRefresh();
Toast.warning(`连续 ${MAX_FAILURES} 次刷新失败,已自动关闭实时刷新`);
}
} finally {
autoRefreshing = false;
}
};
const startAutoRefresh = () => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
consecutiveFailures = 0;
lastRefreshTime.value = null;
updateRelativeTime();
// 相对时间计时器:每秒更新 "N秒前更新" 文案
if (refreshRelativeTimer) clearInterval(refreshRelativeTimer);
refreshRelativeTimer = setInterval(updateRelativeTime, 1000);
autoRefreshTimer = setInterval(() => { if (isPageVisible()) autoRefreshTick(); }, autoRefreshSecs.value * 1000);
};
const stopAutoRefresh = () => {
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
if (refreshRelativeTimer) { clearInterval(refreshRelativeTimer); refreshRelativeTimer = null; }
};
const resetAutoRefreshTimer = () => { if (autoRefresh.value) startAutoRefresh(); };
const toggleAutoRefresh = () => {
autoRefresh.value = !autoRefresh.value;
if (autoRefresh.value) { startAutoRefresh(); Toast.success("已开启实时刷新"); }
else { stopAutoRefresh(); Toast.success("已关闭实时刷新"); }
};
const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) }
const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } }
@@ -204,7 +304,9 @@ const fetchReplies = async () => {
if (filterStatus.value) params.status = filterStatus.value; if (filterSentiment.value) params.sentiment = filterSentiment.value; if (filterKeyword.value) params.keyword = filterKeyword.value;
const { data } = await axiosInstance.get("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies", { params })
replies.value = data.items || []; total.value = data.total || 0; totalPages.value = Math.ceil(total.value / size.value)
} catch (e) { Toast.error("获取数据失败") } finally { loading.value = false }
// 当前页数据为空且非首页时,回退到上一页(处理删除最后一页最后一条后的越界问题)
if (replies.value.length === 0 && page.value > 1 && totalPages.value > 0) { page.value = Math.min(page.value, totalPages.value); }
} catch (e) { Toast.error("获取数据失败"); total.value = 0; totalPages.value = 0; } finally { loading.value = false }
}
const openConversation = async (reply: AiCommentReplyItem) => {
@@ -225,7 +327,12 @@ const batchDelete = async () => { if(!selectedNames.value.size||batchLoading.val
const getStatusLabel = (s: string) => { const m:any = { PASS: '通过', FAIL: '失败', PENDING: '待审', REJECTED: '拒绝', FILTERED: '已拦截', FALSE_POSITIVE: '误报通过' }; return m[s] || s }
const getSentimentLabel = (s: string) => { const m:any = { VERY_POSITIVE: '极好', POSITIVE: '正面', NEUTRAL: '中性', NEGATIVE: '负面', VERY_NEGATIVE: '极差' }; return m[s] || s }
const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : ""
const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}`
const getPostUrl = (slug: string, postKind?: string) => {
if (postKind === "Moment") {
return `${window.location.origin}/moments/${slug}`
}
return `${window.location.origin}/archives/${slug}`
}
const stripHtml = (html: string) => html ? html.replace(/<[^>]+>/g, "").replace(/\n+/g, " ").trim() : ""
const truncateQuote = (content: string, length = 35) => {
@@ -237,22 +344,33 @@ const truncateQuote = (content: string, length = 35) => {
const renderContent = (content: string) => {
if (!content) return "<span style='opacity:0.5'>(空)</span>"
let parsed = content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
// XSS 防护:移除所有 on* 事件处理器、javascript: 协议、script/style/iframe/object/embed 标签
let parsed = content
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, "")
.replace(/<embed[^>]*>/gi, "")
.replace(/\son\w+\s*=\s*"[^"]*"/gi, "")
.replace(/\son\w+\s*=\s*'[^']*'/gi, "")
.replace(/\son\w+\s*=\s*[^\s>]+/gi, "")
.replace(/(href|src)\s*=\s*["']?\s*javascript:/gi, "$1=\"\"")
.replace(/(href|src)\s*=\s*["']?\s*data:text\/html/gi, "$1=\"\"")
parsed = parsed.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[:]\s*/gm, "")
return parsed.replace(/\n/g, "<br/>")
}
const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() }
const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies(); resetAutoRefreshTimer(); }
const openFalsePositiveDialog = (reply: AiCommentReplyItem) => { falsePositiveTarget.value = reply; showFalsePositiveDialog.value = true }
const closeFalsePositiveDialog = () => { showFalsePositiveDialog.value = false; falsePositiveTarget.value = null }
const handleFalsePositive = async (action: string) => {
if (!falsePositiveTarget.value) return
fpLoading.value = true
try {
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${falsePositiveTarget.value.metadata.name}/false-positive`, { action })
Toast.success(action === "aiReply" ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过")
showFalsePositiveDialog.value = false
falsePositiveTarget.value = null
closeFalsePositiveDialog()
fetchReplies()
} catch (e: any) {
Toast.error(e?.response?.data?.message || "操作失败")
@@ -270,15 +388,23 @@ const handleTriggerAiReply = async (reply: AiCommentReplyItem) => {
} finally { triggerAiLoadingName.value = null }
}
// 状态/情感筛选立即触发;关键词输入防抖 300ms 避免每次按键都请求
watch([filterStatus, filterSentiment], () => { page.value = 1; fetchReplies() })
watch([filterStatus, filterSentiment], () => { page.value = 1; fetchReplies(); resetAutoRefreshTimer(); })
let keywordDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(filterKeyword, () => {
if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer)
keywordDebounceTimer = setTimeout(() => { page.value = 1; fetchReplies() }, 300)
keywordDebounceTimer = setTimeout(() => { page.value = 1; fetchReplies(); resetAutoRefreshTimer(); }, 300)
})
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies(); resetAutoRefreshTimer(); })
// 刷新间隔变化时重启计时器
watch(autoRefreshSecs, () => { if (autoRefresh.value) startAutoRefresh(); })
// 标签页重新可见时,若开启了实时刷新则立即拉取一次,保证回到页面时数据是最新的
const handleVisibilityChange = () => { if (!document.hidden && autoRefresh.value) autoRefreshTick(); };
onMounted(() => { fetchReplies(); document.addEventListener("visibilitychange", handleVisibilityChange); })
onUnmounted(() => {
if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer);
stopAutoRefresh();
document.removeEventListener("visibilitychange", handleVisibilityChange);
})
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
onMounted(fetchReplies)
onUnmounted(() => { if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer) })
</script>
<style scoped>
@@ -300,6 +426,16 @@ onUnmounted(() => { if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer)
@media (min-width: 768px) { .filter-select { width: auto; min-width: 120px; } .filter-input { flex: 1; } }
.btn-reset { padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; cursor: pointer; white-space: nowrap; width: 100%; }
@media (min-width: 768px) { .btn-reset { width: auto; } }
.autorefresh-group { display: flex; align-items: center; gap: 6px; width: 100%; flex-wrap: wrap; }
@media (min-width: 768px) { .autorefresh-group { width: auto; flex-wrap: nowrap; } }
.btn-autorefresh { padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; cursor: pointer; white-space: nowrap; font-size: 13px; color: #6b7280; display: flex; align-items: center; gap: 6px; transition: all 0.15s; }
.btn-autorefresh:hover { background: #f3f4f6; }
.btn-autorefresh.is-active { background: #dcfce7; border-color: #86efac; color: #15803d; }
.autorefresh-dot { width: 8px; height: 8px; border-radius: 50%; background: #16a34a; animation: autorefresh-pulse 1.5s ease-in-out infinite; }
@keyframes autorefresh-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.85); } }
.autorefresh-interval { padding: 6px 8px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; font-size: 13px; color: #6b7280; cursor: pointer; }
.autorefresh-interval:focus { outline: none; border-color: #86efac; }
.autorefresh-status { font-size: 12px; color: #9ca3af; white-space: nowrap; min-width: 70px; }
/* 列表区 */
.list-area { margin: 16px; }
+28 -10
View File
@@ -65,6 +65,10 @@
<div class="form-row__label"><span class="form-label">违规评论设为待审核</span><span class="form-hint">检测到违规评论时自动取消通过需人工审核</span></div>
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterPendingOnViolation" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
</div>
<div v-if="momentsAvailable" class="form-row">
<div class="form-row__label"><span class="form-label">瞬间评论区适配</span><span class="form-hint">为瞬间插件(Moments)的评论区启用AI自动回复</span></div>
<label class="toggle"><input type="checkbox" v-model="settings.basic.momentsEnabled" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
</div>
</div>
</div>
@@ -118,14 +122,14 @@
</div>
</div>
<!-- 4. Prompt设置 -->
<!-- 4. 提示词设置 -->
<div v-if="activeTab === 'prompt'" class="setting-panel">
<div class="panel-header section-header--amber">
<div class="section-header__text"><h3>Prompt设置</h3><p>自定义AI回复的提示词模板</p></div>
<div class="section-header__text"><h3>提示词设置</h3><p>自定义AI回复的提示词模板</p></div>
</div>
<div class="panel-body">
<div class="form-field">
<label class="form-label">Prompt预设</label>
<label class="form-label">提示词预设</label>
<div class="preset-grid">
<label v-for="p in promptPresets" :key="p.key" class="preset-item" :class="{ 'preset-item--active': isPresetEnabled(p.key) }">
<input type="checkbox" :checked="isPresetEnabled(p.key)" @change="togglePreset(p.key)" class="preset-checkbox" />
@@ -134,8 +138,8 @@
</div>
</div>
<div class="form-field">
<label class="form-label">自定义Prompt模板</label>
<textarea v-model="settings.prompt.customPromptTemplate" rows="10" class="form-textarea form-textarea--mono" placeholder="自定义Prompt模板"></textarea>
<label class="form-label">自定义提示词模板</label>
<textarea v-model="settings.prompt.customPromptTemplate" rows="10" class="form-textarea form-textarea--mono" placeholder="自定义提示词模板"></textarea>
</div>
</div>
</div>
@@ -238,7 +242,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue"
import { ref, reactive, computed, onMounted, onUnmounted, watch } from "vue"
import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace, IconPlug } from "@halo-dev/components"
@@ -247,17 +251,19 @@ const tabItems = [
{ label: "基本设置", value: "basic" },
{ label: "AI角色", value: "persona" },
{ label: "模型设置", value: "model" },
{ label: "Prompt", value: "prompt" },
{ label: "提示词", value: "prompt" },
{ label: "数据清理", value: "cleanup" },
]
const promptVariables = [
{ name: '{{persona_prompt}}', desc: 'AI角色人格提示词(含已启用的预设)' },
{ name: '{{safety_prompt}}', desc: '安全规范提示词' },
{ name: '{{output_guidance}}', desc: '输出规范(回复长度、风格约束等)' },
{ name: '{{sentiment_hint}}', desc: '情感提示(根据评论情绪自动生成,可省略)' },
{ name: '{{post_title}}', desc: '文章标题' },
{ name: '{{post_date}}', desc: '文章发布日期' },
{ name: '{{comment_count}}', desc: '该文章的评论数' },
{ name: '{{article}}', desc: '文章/页面内容(含标题)' },
{ name: '{{article}}', desc: '文章/页面内容' },
{ name: '{{conversation_history}}', desc: '对话历史上下文' },
{ name: '{{comment}}', desc: '评论内容(含评论者名称)' },
]
@@ -270,12 +276,23 @@ const promptPresets = [
]
const settings = reactive({
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", maxConversationRounds: 8, rateLimitPerMinute: 10, preFilterEnabled: true, preFilterPendingOnViolation: true },
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", maxConversationRounds: 8, rateLimitPerMinute: 10, preFilterEnabled: true, preFilterPendingOnViolation: true, momentsEnabled: true },
model: { modelName: "" },
prompt: { customPromptTemplate: "", enabledPresets: [] as string[] },
cleanup: { cleanupEnabled: true, retentionDays: 30 },
})
// 瞬间插件可用性:仅当检测到瞬间插件已安装并启用时才显示对应开关
const momentsAvailable = ref(false)
const fetchMomentsStatus = async () => {
try {
const { data } = await axiosInstance.get(`${apiBase}/moments-status`)
momentsAvailable.value = !!(data?.installed || data?.enabled)
} catch {
momentsAvailable.value = false
}
}
const loading = ref(false)
const saving = ref(false)
const lastSavedSnapshot = ref("")
@@ -330,7 +347,8 @@ const parseCfg = (d:any, k:string) => { const v = d[k]; if(!v) return {}; if(typ
const fetchSettings = async () => { loading.value=true; try { const { data } = await coreApiClient.configMap.getConfigMap({ name: configMapName }); if(data.data) { const d:any = data.data; const b = parseCfg(d,'basic'); const m = parseCfg(d,'model'); const p = parseCfg(d,'prompt'); const c = parseCfg(d,'cleanup'); if(b.autoReply !== undefined) Object.assign(settings.basic, b); if(m.modelName !== undefined) settings.model.modelName = m.modelName; if(p.customPromptTemplate !== undefined) { settings.prompt.customPromptTemplate = p.customPromptTemplate; settings.prompt.enabledPresets = Array.isArray(p.enabledPresets) ? p.enabledPresets : (p.enabledPresets||'').split(',').filter(Boolean) }; if(c.retentionDays !== undefined) Object.assign(settings.cleanup, c) } } catch(e){} finally { loading.value=false; lastSavedSnapshot.value = JSON.stringify(settings) } }
const saveSettings = async () => { saving.value=true; try { const { data:l } = await coreApiClient.configMap.getConfigMap({ name: configMapName }); l.data = { ...l.data, basic: JSON.stringify(settings.basic), model: JSON.stringify(settings.model), prompt: JSON.stringify(settings.prompt), cleanup: JSON.stringify(settings.cleanup) }; await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: l }); Toast.success("保存成功"); lastSavedSnapshot.value = JSON.stringify(settings) } catch(e){ Toast.error("保存失败") } finally { saving.value=false } }
onMounted(async () => { await fetchSettings(); await fetchPersonas(); await computePersonaAvatars() })
onMounted(async () => { await fetchSettings(); await fetchMomentsStatus(); await fetchPersonas(); await computePersonaAvatars() })
onUnmounted(() => { clearTimeout(emailDebounce) })
</script>
<style scoped>