feat: add prompt history management

This commit is contained in:
2026-07-13 14:06:45 +08:00
parent cb3b91b101
commit 44ab895149
9 changed files with 876 additions and 230 deletions

View File

@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import AdminLoginView from "./components/AdminLoginView.vue"; import AdminLoginView from "./components/AdminLoginView.vue";
import AdminPagination from "./components/AdminPagination.vue"; import AdminPagination from "./components/AdminPagination.vue";
import AgentManagementView from "./components/AgentManagementView.vue";
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue"; import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
import TableRowActions from "./components/TableRowActions.vue"; import TableRowActions from "./components/TableRowActions.vue";
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings"; import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
@@ -11,7 +12,6 @@ import { api, clearToken, getToken, saveToken } from "./services/api";
import type { import type {
AdminProfile, AdminProfile,
AdminUser, AdminUser,
AgentDebugResult,
AiLogRecord, AiLogRecord,
ChatDetail, ChatDetail,
ChatRecord, ChatRecord,
@@ -40,7 +40,6 @@ const dashboardFilters = reactive({
}); });
const users = ref<AdminUser[]>([]); const users = ref<AdminUser[]>([]);
const userKeyword = ref(""); const userKeyword = ref("");
const knowledge = ref<KnowledgeItem[]>([]);
const models = ref<ModelItem[]>([]); const models = ref<ModelItem[]>([]);
const configs = ref<SystemConfigItem[]>([]); const configs = ref<SystemConfigItem[]>([]);
const chats = ref<ChatRecord[]>([]); const chats = ref<ChatRecord[]>([]);
@@ -50,7 +49,7 @@ const retrievalLogs = ref<RetrievalLogItem[]>([]);
const attentionRecords = ref<AttentionItem[]>([]); const attentionRecords = ref<AttentionItem[]>([]);
const selectedRetrievalLog = ref<Record<string, unknown> | null>(null); const selectedRetrievalLog = ref<Record<string, unknown> | null>(null);
const retrievalDetailOpen = ref(false); const retrievalDetailOpen = ref(false);
const promptContent = ref(""); const previewKnowledgeId = ref<number | null>(null);
const chatDetail = ref<ChatDetail | null>(null); const chatDetail = ref<ChatDetail | null>(null);
const chatDetailOpen = ref(false); const chatDetailOpen = ref(false);
const selectedAiLog = ref<AiLogRecord | null>(null); const selectedAiLog = ref<AiLogRecord | null>(null);
@@ -68,11 +67,6 @@ const editingModelId = ref<number | null>(null);
const editingKnowledgeId = ref<number | null>(null); const editingKnowledgeId = ref<number | null>(null);
const batchNodeToken = ref(""); const batchNodeToken = ref("");
const batchImporting = ref(false); const batchImporting = ref(false);
const agentDebugging = ref(false);
const agentDebugTrace = ref<Record<string, unknown>[]>([]);
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
]);
const studentForm = reactive({ const studentForm = reactive({
phone: "", phone: "",
@@ -207,18 +201,6 @@ const chatFilters = reactive({
dateTo: "", dateTo: "",
}); });
const agentForm = reactive({
modelId: undefined as number | undefined,
knowledgeIds: [] as number[],
temperature: 0.2 as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 1024 as number | null,
question: "",
});
watch(recordTab, async (tab) => { watch(recordTab, async (tab) => {
if (activeMenu.value === "records") await loadRecordTab(tab); if (activeMenu.value === "records") await loadRecordTab(tab);
}); });
@@ -262,8 +244,8 @@ async function switchMenu(menu: string) {
} }
async function previewKnowledge(knowledgeId: number) { async function previewKnowledge(knowledgeId: number) {
previewKnowledgeId.value = knowledgeId;
await switchMenu("prompt"); await switchMenu("prompt");
agentForm.knowledgeIds = [knowledgeId];
} }
async function loadDashboard() { async function loadDashboard() {
@@ -282,16 +264,6 @@ async function loadCurrentMenu() {
try { try {
if (activeMenu.value === "dashboard") await loadDashboard(); if (activeMenu.value === "dashboard") await loadDashboard();
if (activeMenu.value === "users") await loadUsers(pagers.users.page, pagers.users.pageSize); if (activeMenu.value === "users") await loadUsers(pagers.users.page, pagers.users.pageSize);
if (activeMenu.value === "prompt") {
const [prompt, modelRows, knowledgeRows] = await Promise.all([api.prompt(), api.models(), api.knowledgeOptions()]);
promptContent.value = prompt.promptContent;
models.value = modelRows;
knowledge.value = knowledgeRows;
agentForm.knowledgeIds = knowledgeRows.filter((item) => item.status === 1 && item.lifecycleStatus === "active").map((item) => item.id);
if (!agentForm.modelId && modelRows.length) {
agentForm.modelId = modelRows.find((model) => model.enabled === 1)?.id ?? modelRows[0].id;
}
}
if (activeMenu.value === "models") models.value = await api.models(); if (activeMenu.value === "models") models.value = await api.models();
if (activeMenu.value === "configs") { if (activeMenu.value === "configs") {
configs.value = await api.configs(); configs.value = await api.configs();
@@ -478,18 +450,6 @@ async function batchImportKnowledge() {
} }
} }
async function savePrompt() {
const result = await api.savePrompt(promptContent.value);
promptContent.value = result.promptContent;
ElMessage.success("Prompt 已保存");
}
async function resetPrompt() {
const result = await api.resetPrompt();
promptContent.value = result.promptContent;
ElMessage.success("Prompt 已恢复默认");
}
async function saveModel() { async function saveModel() {
if (!validateModelForm()) return; if (!validateModelForm()) return;
const payload = buildModelPayload(); const payload = buildModelPayload();
@@ -743,66 +703,6 @@ function serializeSystemSettingValue(value: SystemSettingValue) {
return String(value); return String(value);
} }
function agentMessageParts(content: string) {
const matched = content.match(/<think>([\s\S]*?)<\/think>/i);
if (!matched) return { reasoning: "", answer: content };
const answer = content.replace(matched[0], "").trim();
return {
reasoning: matched[1].trim(),
answer: answer || "模型未返回正式回答。",
};
}
async function debugAgent() {
if (!agentForm.modelId) {
ElMessage.error("请选择调试模型");
return;
}
if (!agentForm.question.trim()) {
ElMessage.error("请输入调试问题");
return;
}
const question = agentForm.question.trim();
agentPreviewMessages.value.push({ role: "user", content: question });
agentForm.question = "";
agentDebugging.value = true;
try {
const result: AgentDebugResult = await api.debugAgent({
promptContent: promptContent.value,
modelId: agentForm.modelId,
knowledgeIds: agentForm.knowledgeIds,
question,
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
});
if (!result.ok) {
ElMessage.error(result.message);
agentPreviewMessages.value.push({ role: "assistant", content: result.message });
return;
}
agentPreviewMessages.value.push({ role: "assistant", content: result.answer });
agentDebugTrace.value = result.retrievalTrace || [];
ElMessage.success(result.message);
} catch (error) {
const message = error instanceof Error ? error.message : "Agent 调试失败";
ElMessage.error(message);
agentPreviewMessages.value.push({ role: "assistant", content: message });
} finally {
agentDebugging.value = false;
}
}
function clearAgentPreview() {
agentPreviewMessages.value = [
{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" },
];
agentDebugTrace.value = [];
}
async function openRetrievalDetail(row: RetrievalLogItem) { async function openRetrievalDetail(row: RetrievalLogItem) {
selectedRetrievalLog.value = await api.retrievalLogDetail(row.id); selectedRetrievalLog.value = await api.retrievalLogDetail(row.id);
retrievalDetailOpen.value = true; retrievalDetailOpen.value = true;
@@ -1026,118 +926,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
<template v-if="activeMenu === 'prompt'"> <template v-if="activeMenu === 'prompt'">
<div class="page-head inline"> <AgentManagementView :preview-knowledge-id="previewKnowledgeId" @consumed-preview="previewKnowledgeId = null" />
<div><h2>Agent 管理</h2><p>配置提示词模型知识库和调试参数并在右侧实时预览对话效果</p></div>
<el-button @click="resetPrompt">恢复默认提示词</el-button>
</div>
<section class="agent-workbench">
<div class="agent-config-panel">
<el-form label-position="top">
<el-form-item label="Agent 提示词">
<el-input v-model="promptContent" type="textarea" :rows="10" />
</el-form-item>
<div class="agent-config-grid">
<el-form-item label="调试模型">
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型">
<el-option
v-for="model in models"
:key="model.id"
:label="model.displayName || model.modelName"
:value="model.id"
/>
</el-select>
</el-form-item>
<el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="Temperature">
<el-input-number v-model="agentForm.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Top P">
<el-input-number v-model="agentForm.topP" :min="0" :max="1" :step="0.05" />
</el-form-item>
<el-form-item label="Top K">
<el-input-number v-model="agentForm.topK" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="agentForm.maxToken" :min="1" :max="100000" />
</el-form-item>
<el-form-item label="Presence Penalty">
<el-input-number v-model="agentForm.presencePenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Frequency Penalty">
<el-input-number v-model="agentForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
</div>
<div class="actions">
<el-button type="primary" @click="savePrompt">保存 Agent 提示词</el-button>
<el-button @click="clearAgentPreview">清空预览</el-button>
</div>
</el-form>
</div>
<aside class="agent-preview-panel">
<div class="agent-preview-head">
<h3>调试预览</h3>
<span>{{ models.find((model) => model.id === agentForm.modelId)?.modelName || "未选择模型" }}</span>
</div>
<div class="agent-preview-chat">
<article
v-for="(message, index) in agentPreviewMessages"
:key="`${message.role}-${index}`"
class="agent-preview-message-row"
:class="message.role"
>
<div class="agent-preview-avatar">{{ message.role === "user" ? "你" : "AI" }}</div>
<div class="agent-preview-bubble">
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
<template v-if="message.role === 'assistant'">
<details v-if="agentMessageParts(message.content).reasoning" class="agent-preview-thinking">
<summary>思考过程</summary>
<pre>{{ agentMessageParts(message.content).reasoning }}</pre>
</details>
<pre>{{ agentMessageParts(message.content).answer }}</pre>
</template>
<pre v-else>{{ message.content }}</pre>
</div>
</article>
<article v-if="agentDebugging" class="agent-preview-message-row assistant thinking">
<div class="agent-preview-avatar">AI</div>
<div class="agent-preview-bubble">
<strong>Agent</strong>
<pre>正在思考中...</pre>
</div>
</article>
</div>
<div class="agent-preview-composer">
<el-input
v-model="agentForm.question"
type="textarea"
:rows="2"
resize="none"
placeholder="向 Agent 发送测试问题"
@keydown.enter.exact.prevent="debugAgent"
/>
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
</div>
</aside>
<aside class="agent-trace-panel">
<div class="agent-preview-head">
<h3>检索追踪</h3>
<span>本轮工具调用</span>
</div>
<div class="agent-trace-list">
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
</details>
</div>
</aside>
</section>
</template> </template>
<template v-if="activeMenu === 'models'"> <template v-if="activeMenu === 'models'">

View File

@@ -0,0 +1,388 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type { AgentDebugResult, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
const props = defineProps<{ previewKnowledgeId?: number | null }>();
const emit = defineEmits<{ consumedPreview: [] }>();
const loading = ref(false);
const promptSaving = ref(false);
const historyLoading = ref(false);
const agentDebugging = ref(false);
const activeConfigTab = ref("prompt");
const prompt = ref<PromptDetail | null>(null);
const promptContent = ref("");
const savedPromptContent = ref("");
const models = ref<ModelItem[]>([]);
const knowledge = ref<KnowledgeItem[]>([]);
const history = ref<PromptHistoryItem[]>([]);
const historyPager = reactive({ page: 1, pageSize: 10, total: 0 });
const historyDetailOpen = ref(false);
const selectedHistory = ref<PromptDetail | null>(null);
const historyDetailLoading = ref(false);
const agentDebugTrace = ref<Record<string, any>[]>([]);
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
]);
const agentForm = reactive({
modelId: undefined as number | undefined,
knowledgeIds: [] as number[],
temperature: 0.2 as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 1024 as number | null,
question: "",
});
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
const selectedModelName = computed(() => {
const model = models.value.find((item) => item.id === agentForm.modelId);
return model?.displayName || model?.modelName || "未选择模型";
});
const selectedKnowledgeSummary = computed(() => {
if (!agentForm.knowledgeIds.length) return "未选择知识库";
if (agentForm.knowledgeIds.length === 1) {
return knowledge.value.find((item) => item.id === agentForm.knowledgeIds[0])?.name || "1 个知识库";
}
return `${agentForm.knowledgeIds.length} 个知识库`;
});
onMounted(load);
watch(() => props.previewKnowledgeId, (knowledgeId) => {
if (knowledgeId && knowledge.value.length) applyPreviewKnowledge(knowledgeId);
});
async function load() {
loading.value = true;
try {
const [promptResult, modelRows, knowledgeRows] = await Promise.all([
api.prompt(), api.models(), api.knowledgeOptions(),
]);
applyPrompt(promptResult);
models.value = modelRows;
knowledge.value = knowledgeRows;
agentForm.modelId = modelRows.find((item) => item.enabled === 1)?.id ?? modelRows[0]?.id;
agentForm.knowledgeIds = knowledgeRows
.filter((item) => item.status === 1 && item.lifecycleStatus === "active")
.map((item) => item.id);
if (props.previewKnowledgeId) applyPreviewKnowledge(props.previewKnowledgeId);
await loadHistory();
} catch (error) {
ElMessage.error(errorMessage(error, "Agent 配置加载失败"));
} finally {
loading.value = false;
}
}
function applyPreviewKnowledge(knowledgeId: number) {
if (knowledge.value.some((item) => item.id === knowledgeId)) {
agentForm.knowledgeIds = [knowledgeId];
activeConfigTab.value = "debug";
}
emit("consumedPreview");
}
function applyPrompt(value: PromptDetail) {
prompt.value = value;
promptContent.value = value.promptContent;
savedPromptContent.value = value.promptContent;
}
async function savePrompt() {
if (!promptContent.value.trim()) return ElMessage.warning("主提示词不能为空");
promptSaving.value = true;
try {
applyPrompt(await api.savePrompt(promptContent.value.trim()));
ElMessage.success("主提示词已保存为新版本");
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "主提示词保存失败"));
} finally {
promptSaving.value = false;
}
}
async function resetPrompt() {
try {
await ElMessageBox.confirm(
"恢复默认提示词会新增一个版本,现有历史不会被删除。确认继续?",
"恢复默认提示词",
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
);
} catch {
return;
}
promptSaving.value = true;
try {
applyPrompt(await api.resetPrompt());
ElMessage.success("已恢复默认提示词并保存为新版本");
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "恢复默认提示词失败"));
} finally {
promptSaving.value = false;
}
}
async function loadHistory(page = historyPager.page, pageSize = historyPager.pageSize) {
historyLoading.value = true;
try {
const result = await api.promptHistory({ page, pageSize });
history.value = result.items;
Object.assign(historyPager, { page: result.page, pageSize: result.pageSize, total: result.total });
} catch (error) {
ElMessage.error(errorMessage(error, "提示词历史加载失败"));
} finally {
historyLoading.value = false;
}
}
async function openHistory(item: PromptHistoryItem) {
historyDetailOpen.value = true;
historyDetailLoading.value = true;
selectedHistory.value = null;
try {
selectedHistory.value = await api.promptHistoryDetail(item.id);
} catch (error) {
ElMessage.error(errorMessage(error, "提示词版本加载失败"));
historyDetailOpen.value = false;
} finally {
historyDetailLoading.value = false;
}
}
async function restoreHistory(id: number) {
try {
await ElMessageBox.confirm(
`确认把版本 #${id} 恢复为当前主提示词?系统会新增一个恢复版本,不会覆盖历史。`,
"恢复历史版本",
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
);
} catch {
return;
}
promptSaving.value = true;
try {
applyPrompt(await api.restorePrompt(id));
historyDetailOpen.value = false;
activeConfigTab.value = "prompt";
ElMessage.success(`版本 #${id} 已恢复为当前主提示词`);
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "历史版本恢复失败"));
} finally {
promptSaving.value = false;
}
}
async function restoreSelectedHistory() {
if (selectedHistory.value?.id) await restoreHistory(selectedHistory.value.id);
}
async function debugAgent() {
if (!agentForm.modelId) return ElMessage.error("请选择调试模型");
if (!agentForm.question.trim()) return ElMessage.error("请输入调试问题");
if (!promptContent.value.trim()) return ElMessage.error("主提示词不能为空");
const question = agentForm.question.trim();
agentPreviewMessages.value.push({ role: "user", content: question });
agentForm.question = "";
agentDebugging.value = true;
try {
const result: AgentDebugResult = await api.debugAgent({
promptContent: promptContent.value,
modelId: agentForm.modelId,
knowledgeIds: agentForm.knowledgeIds,
question,
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
});
if (!result.ok) {
agentPreviewMessages.value.push({ role: "assistant", content: result.message });
return ElMessage.error(result.message);
}
agentPreviewMessages.value.push({ role: "assistant", content: result.answer });
agentDebugTrace.value = result.retrievalTrace || [];
ElMessage.success(result.message);
} catch (error) {
const message = errorMessage(error, "Agent 调试失败");
agentPreviewMessages.value.push({ role: "assistant", content: message });
ElMessage.error(message);
} finally {
agentDebugging.value = false;
}
}
function clearAgentPreview() {
agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }];
agentDebugTrace.value = [];
}
function agentMessageParts(content: string) {
const matched = content.match(/<think>([\s\S]*?)<\/think>/i);
if (!matched) return { reasoning: "", answer: content };
return {
reasoning: matched[1].trim(),
answer: content.replace(matched[0], "").trim() || "模型未返回正式回答。",
};
}
function changeTypeLabel(value?: string) {
return ({ save: "手动保存", reset: "恢复默认", restore: "历史恢复", default: "系统默认" } as Record<string, string>)[value || ""] || "保存";
}
function changeTypeTag(value?: string) {
if (value === "reset") return "warning";
if (value === "restore") return "success";
return "info";
}
function formatDate(value?: string | null) {
if (!value) return "尚未保存";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
</script>
<template>
<div class="page-head agent-page-head">
<div>
<h2>Agent 管理</h2>
<p>维护主提示词及历史版本配置本次调试参数并验证真实检索与回答效果</p>
</div>
<div class="agent-current-summary">
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDate(prompt?.updatedAt) }}</span>
</div>
</div>
<section v-loading="loading" class="agent-workbench agent-workbench-v2">
<div class="agent-config-panel agent-control-panel">
<el-tabs v-model="activeConfigTab" class="agent-config-tabs">
<el-tab-pane label="主提示词" name="prompt">
<div class="agent-section-intro">
<div><h3>当前主提示词</h3><p>保存恢复默认或恢复历史版本时都会新增版本历史记录不会被覆盖</p></div>
<el-tag v-if="promptDirty" type="warning">有未保存修改</el-tag>
</div>
<el-input v-model="promptContent" class="agent-prompt-editor" type="textarea" :rows="18" resize="vertical" placeholder="请输入 Agent 主提示词" />
<div class="agent-editor-meta"><span>{{ promptContent.length }} 字符</span><span>调试会立即使用编辑框中的内容</span></div>
<div class="actions agent-primary-actions">
<el-button type="primary" :loading="promptSaving" :disabled="!promptDirty" @click="savePrompt">保存为新版本</el-button>
<el-button :loading="promptSaving" @click="resetPrompt">恢复默认提示词</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="调试配置" name="debug">
<div class="agent-section-intro"><div><h3>本次调试配置</h3><p>配置只作用于后台预览不会修改模型和知识库的正式状态</p></div></div>
<el-form label-position="top">
<el-form-item label="调试模型">
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型">
<el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" />
</el-select>
</el-form-item>
<el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
</el-select>
</el-form-item>
<el-collapse class="agent-advanced-settings">
<el-collapse-item title="高级生成参数" name="generation">
<div class="agent-config-grid">
<el-form-item label="Temperature"><el-input-number v-model="agentForm.temperature" :min="0" :max="2" :step="0.1" /></el-form-item>
<el-form-item label="Top P"><el-input-number v-model="agentForm.topP" :min="0" :max="1" :step="0.05" /></el-form-item>
<el-form-item label="Top K"><el-input-number v-model="agentForm.topK" :min="1" :max="1000" /></el-form-item>
<el-form-item label="Max Tokens"><el-input-number v-model="agentForm.maxToken" :min="1" :max="100000" /></el-form-item>
<el-form-item label="Presence Penalty"><el-input-number v-model="agentForm.presencePenalty" :min="-2" :max="2" :step="0.1" /></el-form-item>
<el-form-item label="Frequency Penalty"><el-input-number v-model="agentForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" /></el-form-item>
</div>
</el-collapse-item>
</el-collapse>
</el-form>
</el-tab-pane>
<el-tab-pane label="历史版本" name="history">
<div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本恢复操作会作为一个新版本记录</p></div></div>
<div v-loading="historyLoading" class="prompt-history-list">
<el-empty v-if="history.length === 0" description="还没有已保存的提示词版本" :image-size="64" />
<article v-for="item in history" :key="item.id" class="prompt-history-item" :class="{ current: item.isCurrent }">
<div class="prompt-history-head">
<strong>版本 #{{ item.id }}</strong>
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag>
<el-tag :type="changeTypeTag(item.changeType)" size="small">{{ changeTypeLabel(item.changeType) }}</el-tag>
<span>{{ item.updatedByName }} · {{ formatDate(item.updatedAt) }}</span>
</div>
<p>{{ item.preview || "空内容" }}</p>
<div class="prompt-history-foot">
<span>{{ item.charCount }} 字符<span v-if="item.sourcePromptId"> · 来源 #{{ item.sourcePromptId }}</span></span>
<div><el-button link type="primary" @click="openHistory(item)">查看完整内容</el-button><el-button v-if="!item.isCurrent" link type="primary" @click="restoreHistory(item.id)">恢复此版本</el-button></div>
</div>
</article>
</div>
<AdminPagination :page="historyPager.page" :page-size="historyPager.pageSize" :total="historyPager.total" @change="loadHistory" />
</el-tab-pane>
</el-tabs>
</div>
<aside class="agent-preview-panel">
<div class="agent-preview-head">
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }}</small></div>
<div class="agent-preview-head-actions"><span>{{ selectedModelName }}</span><el-button link @click="clearAgentPreview">清空</el-button></div>
</div>
<div class="agent-preview-chat">
<article v-for="(message, index) in agentPreviewMessages" :key="`${message.role}-${index}`" class="agent-preview-message-row" :class="message.role">
<div class="agent-preview-avatar">{{ message.role === "user" ? "你" : "AI" }}</div>
<div class="agent-preview-bubble">
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
<template v-if="message.role === 'assistant'">
<details v-if="agentMessageParts(message.content).reasoning" class="agent-preview-thinking"><summary>思考过程</summary><pre>{{ agentMessageParts(message.content).reasoning }}</pre></details>
<pre>{{ agentMessageParts(message.content).answer }}</pre>
</template>
<pre v-else>{{ message.content }}</pre>
</div>
</article>
<article v-if="agentDebugging" class="agent-preview-message-row assistant thinking"><div class="agent-preview-avatar">AI</div><div class="agent-preview-bubble"><strong>Agent</strong><pre>正在思考中...</pre></div></article>
</div>
<div class="agent-preview-composer">
<el-input v-model="agentForm.question" type="textarea" :rows="2" resize="none" placeholder="向 Agent 发送测试问题Enter 发送Shift+Enter 换行" @keydown.enter.exact.prevent="debugAgent" />
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
</div>
</aside>
<aside class="agent-trace-panel agent-trace-wide">
<div class="agent-preview-head"><div><h3>检索追踪</h3><small>按调用顺序查看请求响应耗时与错误</small></div><span>本轮 {{ agentDebugTrace.length }} Tool 调用</span></div>
<div class="agent-trace-list">
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
</details>
</div>
</aside>
</section>
<el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
<div v-loading="historyDetailLoading" class="prompt-history-detail">
<template v-if="selectedHistory">
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDate(selectedHistory.updatedAt) }} · {{ selectedHistory.charCount }} 字符</span></div>
<pre>{{ selectedHistory.promptContent }}</pre>
</template>
</div>
<template #footer><el-button @click="historyDetailOpen = false">关闭</el-button><el-button v-if="selectedHistory?.id !== prompt?.id" type="primary" :loading="promptSaving" @click="restoreSelectedHistory">恢复为当前主提示词</el-button></template>
</el-dialog>
</template>

View File

@@ -21,6 +21,8 @@ import type {
SystemConfigItem, SystemConfigItem,
UserImportResult, UserImportResult,
PageResult, PageResult,
PromptDetail,
PromptHistoryItem,
} from "../types/api"; } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
@@ -154,10 +156,14 @@ export const api = {
migrationSummary: () => request<MigrationSummary>("/admin/knowledge-migration/summary"), migrationSummary: () => request<MigrationSummary>("/admin/knowledge-migration/summary"),
runKnowledgeMigration: (knowledgeId?: number) => runKnowledgeMigration: (knowledgeId?: number) =>
request<{ total: number; success: number; results: Record<string, unknown>[] }>(`/admin/knowledge-migration/run${knowledgeId ? `?knowledgeId=${knowledgeId}` : ""}`, { method: "POST", body: "{}" }), request<{ total: number; success: number; results: Record<string, unknown>[] }>(`/admin/knowledge-migration/run${knowledgeId ? `?knowledgeId=${knowledgeId}` : ""}`, { method: "POST", body: "{}" }),
prompt: () => request<{ promptContent: string }>("/admin/prompt"), prompt: () => request<PromptDetail>("/admin/prompt"),
savePrompt: (promptContent: string) => savePrompt: (promptContent: string) =>
request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }), request<PromptDetail>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
resetPrompt: () => request<{ promptContent: string }>("/admin/prompt/reset", { method: "POST", body: "{}" }), resetPrompt: () => request<PromptDetail>("/admin/prompt/reset", { method: "POST", body: "{}" }),
promptHistory: (query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<PromptHistoryItem>>(`/admin/prompt/history${queryString(query)}`),
promptHistoryDetail: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}`),
restorePrompt: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}/restore`, { method: "POST", body: "{}" }),
debugAgent: (payload: Record<string, unknown>) => debugAgent: (payload: Record<string, unknown>) =>
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }), request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
models: () => request<ModelItem[]>("/admin/model/list"), models: () => request<ModelItem[]>("/admin/model/list"),

View File

@@ -635,11 +635,175 @@ textarea {
.agent-workbench { .agent-workbench {
display: grid; display: grid;
grid-template-columns: minmax(380px, 0.9fr) minmax(420px, 1.1fr) minmax(280px, 0.7fr); grid-template-columns: minmax(500px, 0.9fr) minmax(520px, 1.1fr);
gap: 18px; gap: 18px;
align-items: start; align-items: start;
} }
.agent-page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.agent-current-summary {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
color: #667a73;
font-size: 13px;
}
.agent-current-summary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-control-panel {
min-width: 0;
min-height: 620px;
}
.agent-config-tabs > .el-tabs__header {
margin-bottom: 18px;
}
.agent-section-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.agent-section-intro h3,
.agent-section-intro p {
margin: 0;
}
.agent-section-intro h3 {
margin-bottom: 5px;
font-size: 16px;
}
.agent-section-intro p,
.agent-editor-meta,
.agent-preview-head small {
color: #667a73;
font-size: 12px;
line-height: 1.6;
}
.agent-prompt-editor textarea {
height: clamp(300px, calc(100vh - 440px), 420px) !important;
min-height: 300px !important;
line-height: 1.75;
}
.agent-editor-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.agent-primary-actions {
margin-top: 16px;
}
.agent-advanced-settings {
margin-top: 8px;
border: 1px solid #e2eae7;
border-radius: 8px;
padding: 0 14px;
}
.agent-trace-wide {
grid-column: 1 / -1;
height: 420px;
}
.prompt-history-list {
min-height: 240px;
max-height: clamp(320px, calc(100vh - 430px), 520px);
overflow: auto;
padding-right: 4px;
}
.prompt-history-item {
padding: 14px;
border: 1px solid #e1e9e6;
border-radius: 8px;
background: #fff;
}
.prompt-history-item + .prompt-history-item {
margin-top: 10px;
}
.prompt-history-item.current {
border-color: #8dcabb;
background: #f3faf7;
}
.prompt-history-head,
.prompt-history-foot,
.prompt-history-detail-meta {
display: flex;
align-items: center;
gap: 8px;
}
.prompt-history-head > span:last-child {
margin-left: auto;
color: #667a73;
font-size: 12px;
}
.prompt-history-item > p {
display: -webkit-box;
overflow: hidden;
margin: 10px 0;
color: #40524b;
line-height: 1.65;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.prompt-history-foot {
justify-content: space-between;
color: #667a73;
font-size: 12px;
}
.prompt-history-detail-meta {
flex-wrap: wrap;
margin-bottom: 14px;
}
.prompt-history-detail-meta > span:last-child {
margin-left: auto;
color: #667a73;
font-size: 12px;
}
.prompt-history-detail pre {
max-height: 58vh;
overflow: auto;
margin: 0;
padding: 16px;
border: 1px solid #e1e9e6;
border-radius: 8px;
background: #f7faf9;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: "SFMono-Regular", Consolas, "PingFang SC", monospace;
line-height: 1.7;
}
.agent-config-panel, .agent-config-panel,
.agent-preview-panel, .agent-preview-panel,
.agent-trace-panel { .agent-trace-panel {
@@ -752,6 +916,17 @@ textarea {
font-size: 17px; font-size: 17px;
} }
.agent-preview-head > div:first-child h3 {
margin-bottom: 3px;
}
.agent-preview-head-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.agent-preview-head span { .agent-preview-head span {
overflow: hidden; overflow: hidden;
color: #667a73; color: #667a73;
@@ -1178,7 +1353,7 @@ textarea {
@media (max-width: 1450px) { @media (max-width: 1450px) {
.agent-workbench { .agent-workbench {
grid-template-columns: minmax(420px, 1fr) minmax(420px, 1fr); grid-template-columns: minmax(440px, 0.95fr) minmax(460px, 1.05fr);
} }
.agent-trace-panel { .agent-trace-panel {
@@ -1205,6 +1380,15 @@ textarea {
.agent-trace-panel { .agent-trace-panel {
grid-column: auto; grid-column: auto;
} }
.agent-page-head,
.agent-current-summary {
align-items: flex-start;
}
.agent-page-head {
flex-direction: column;
}
} }
@media (max-width: 860px) { @media (max-width: 860px) {
@@ -1212,6 +1396,19 @@ textarea {
min-width: 0; min-width: 0;
} }
.agent-current-summary,
.prompt-history-head,
.prompt-history-foot,
.prompt-history-detail-meta {
align-items: flex-start;
flex-direction: column;
}
.prompt-history-head > span:last-child,
.prompt-history-detail-meta > span:last-child {
margin-left: 0;
}
.login-page { .login-page {
padding: 18px; padding: 18px;
} }

View File

@@ -29,6 +29,27 @@ export interface DashboardStats {
totalToken: number; totalToken: number;
} }
export interface PromptDetail {
id: number | null;
promptContent: string;
changeType: "default" | "save" | "reset" | "restore";
sourcePromptId?: number | null;
updatedByName?: string | null;
updatedAt?: string | null;
charCount: number;
}
export interface PromptHistoryItem {
id: number;
preview: string;
charCount: number;
changeType: "save" | "reset" | "restore";
sourcePromptId?: number | null;
updatedByName: string;
updatedAt: string;
isCurrent: boolean;
}
export interface AdminUser { export interface AdminUser {
id: number; id: number;
phone: string; phone: string;

View File

@@ -0,0 +1,29 @@
"""add prompt history metadata and ordering index
Revision ID: 0010_prompt_history_management
Revises: 0009_logs_storage
"""
from alembic import op
import sqlalchemy as sa
revision = "0010_prompt_history_management"
down_revision = "0009_logs_storage"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_prompt",
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
)
op.add_column("sys_prompt", sa.Column("source_prompt_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_prompt_updated_at_id", "sys_prompt", ["updated_at", "id"])
def downgrade() -> None:
op.drop_index("ix_sys_prompt_updated_at_id", table_name="sys_prompt")
op.drop_column("sys_prompt", "source_prompt_id")
op.drop_column("sys_prompt", "change_type")

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import get_current_admin from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
@@ -32,8 +33,13 @@ DEFAULT_PROMPT = "你是大本营答疑助手。你只能基于提供的知识
@router.get("/prompt") @router.get("/prompt")
def get_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict: def get_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
prompt = db.scalar(select(Prompt).order_by(Prompt.updated_at.desc(), Prompt.id.desc()).limit(1)) row = db.execute(
return api_success({"promptContent": prompt.prompt_content if prompt else DEFAULT_PROMPT}) select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
.limit(1)
).first()
return api_success(_prompt_detail(row[0], row[1]) if row else _default_prompt_detail())
@router.put("/prompt") @router.put("/prompt")
@@ -42,22 +48,91 @@ def save_prompt(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
prompt = Prompt(prompt_content=payload.promptContent, updated_by=current_admin.id) prompt = Prompt(prompt_content=payload.promptContent, change_type="save", updated_by=current_admin.id)
db.add(prompt) db.add(prompt)
db.flush() db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id) OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id)
db.commit() db.commit()
return api_success({"promptContent": prompt.prompt_content}) db.refresh(prompt)
return api_success(_prompt_detail(prompt, current_admin))
@router.post("/prompt/reset") @router.post("/prompt/reset")
def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict: def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
prompt = Prompt(prompt_content=DEFAULT_PROMPT, updated_by=current_admin.id) prompt = Prompt(prompt_content=DEFAULT_PROMPT, change_type="reset", updated_by=current_admin.id)
db.add(prompt) db.add(prompt)
db.flush() db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id) OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id)
db.commit() db.commit()
return api_success({"promptContent": prompt.prompt_content}) db.refresh(prompt)
return api_success(_prompt_detail(prompt, current_admin))
@router.get("/prompt/history")
def prompt_history(
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
total = db.scalar(select(func.count(Prompt.id))) or 0
rows = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [
_prompt_summary(prompt, admin, is_current=index == 0 and page == 1)
for index, (prompt, admin) in enumerate(rows)
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/prompt/history/{prompt_id}")
def prompt_history_detail(
prompt_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
row = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.where(Prompt.id == prompt_id)
).first()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
return api_success(_prompt_detail(row[0], row[1]))
@router.post("/prompt/history/{prompt_id}/restore")
def restore_prompt(
prompt_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
source = db.get(Prompt, prompt_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
restored = Prompt(
prompt_content=source.prompt_content,
change_type="restore",
source_prompt_id=source.id,
updated_by=current_admin.id,
)
db.add(restored)
db.flush()
OperationLogService.write(
db,
admin_id=current_admin.id,
module="prompt",
action="restore",
target_id=restored.id,
)
db.commit()
db.refresh(restored)
return api_success(_prompt_detail(restored, current_admin))
@router.post("/agent/debug") @router.post("/agent/debug")
@@ -296,6 +371,44 @@ def _model_dict(model: ModelConfig) -> dict:
} }
def _default_prompt_detail() -> dict:
return {
"id": None,
"promptContent": DEFAULT_PROMPT,
"changeType": "default",
"sourcePromptId": None,
"updatedByName": "系统默认",
"updatedAt": None,
"charCount": len(DEFAULT_PROMPT),
}
def _prompt_summary(prompt: Prompt, admin: Admin | None, *, is_current: bool) -> dict:
compact = " ".join(prompt.prompt_content.split())
return {
"id": prompt.id,
"preview": compact[:140],
"charCount": len(prompt.prompt_content),
"changeType": prompt.change_type,
"sourcePromptId": prompt.source_prompt_id,
"updatedByName": admin.name if admin else "未知管理员",
"updatedAt": prompt.updated_at,
"isCurrent": is_current,
}
def _prompt_detail(prompt: Prompt, admin: Admin | None = None) -> dict:
return {
"id": prompt.id,
"promptContent": prompt.prompt_content,
"changeType": prompt.change_type,
"sourcePromptId": prompt.source_prompt_id,
"updatedByName": admin.name if admin else None,
"updatedAt": prompt.updated_at,
"charCount": len(prompt.prompt_content),
}
def _agent_knowledge_scopes(db: Session, knowledge_ids: list[int]) -> list[KnowledgeScope]: def _agent_knowledge_scopes(db: Session, knowledge_ids: list[int]) -> list[KnowledgeScope]:
query = select(Knowledge).where(Knowledge.status == 1) query = select(Knowledge).where(Knowledge.status == 1)
if knowledge_ids: if knowledge_ids:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text, func from sqlalchemy import BigInteger, DateTime, Index, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -11,9 +11,14 @@ from app.models.base import Base
class Prompt(Base): class Prompt(Base):
__tablename__ = "sys_prompt" __tablename__ = "sys_prompt"
__table_args__ = (Index("ix_sys_prompt_updated_at_id", "updated_at", "id"),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
)
prompt_content: Mapped[str] = mapped_column(Text, nullable=False) prompt_content: Mapped[str] = mapped_column(Text, nullable=False)
change_type: Mapped[str] = mapped_column(String(20), default="save", nullable=False)
source_prompt_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,98 @@
from datetime import datetime, timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin_settings import (
DEFAULT_PROMPT,
get_prompt,
prompt_history,
prompt_history_detail,
reset_prompt,
restore_prompt,
save_prompt,
)
from app.models import Base
from app.models.admin import Admin
from app.models.ai_config import Prompt
from app.schemas.admin import PromptSaveRequest
def _database() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def _admin() -> Admin:
return Admin(id=1, username="admin", password="hash", name="系统管理员", status=1)
def test_prompt_history_is_paginated_and_excludes_full_content_from_list():
with _database() as db:
admin = _admin()
db.add(admin)
started = datetime(2026, 1, 1)
db.add_all([
Prompt(
id=index,
prompt_content=f"版本 {index} " + ("x" * 500),
change_type="save",
updated_by=admin.id,
updated_at=started + timedelta(minutes=index),
)
for index in range(1, 16)
])
db.commit()
result = prompt_history(page=2, pageSize=10, db=db, current_admin=admin)["data"]
assert result["total"] == 15
assert len(result["items"]) == 5
assert result["items"][0]["id"] == 5
assert result["items"][0]["isCurrent"] is False
assert "promptContent" not in result["items"][0]
assert len(result["items"][0]["preview"]) <= 140
def test_prompt_history_detail_and_restore_create_a_new_current_version():
with _database() as db:
admin = _admin()
db.add(admin)
db.add_all([
Prompt(id=1, prompt_content="旧版主提示词", change_type="save", updated_by=admin.id),
Prompt(id=2, prompt_content="当前主提示词", change_type="save", updated_by=admin.id),
])
db.commit()
detail = prompt_history_detail(1, db=db, current_admin=admin)["data"]
restored = restore_prompt(1, db=db, current_admin=admin)["data"]
current = get_prompt(db=db, current_admin=admin)["data"]
assert detail["promptContent"] == "旧版主提示词"
assert restored["id"] not in {1, 2}
assert restored["changeType"] == "restore"
assert restored["sourcePromptId"] == 1
assert current["id"] == restored["id"]
assert current["promptContent"] == "旧版主提示词"
assert db.scalar(select(Prompt).where(Prompt.id == 2)).prompt_content == "当前主提示词"
def test_save_and_reset_each_create_an_auditable_prompt_version():
with _database() as db:
admin = _admin()
db.add(admin)
db.commit()
saved = save_prompt(PromptSaveRequest(promptContent="新主提示词"), db=db, current_admin=admin)["data"]
reset = reset_prompt(db=db, current_admin=admin)["data"]
versions = db.scalars(select(Prompt).order_by(Prompt.id)).all()
assert saved["changeType"] == "save"
assert reset["changeType"] == "reset"
assert reset["promptContent"] == DEFAULT_PROMPT
assert [(item.change_type, item.prompt_content) for item in versions] == [
("save", "新主提示词"),
("reset", DEFAULT_PROMPT),
]