feat: add prompt history management
This commit is contained in:
@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
import AdminLoginView from "./components/AdminLoginView.vue";
|
||||
import AdminPagination from "./components/AdminPagination.vue";
|
||||
import AgentManagementView from "./components/AgentManagementView.vue";
|
||||
import KnowledgeManagementView from "./components/KnowledgeManagementView.vue";
|
||||
import TableRowActions from "./components/TableRowActions.vue";
|
||||
import { systemSettingDefinitions, systemSettingSections, type SystemSettingValue } from "./config/systemSettings";
|
||||
@@ -11,7 +12,6 @@ import { api, clearToken, getToken, saveToken } from "./services/api";
|
||||
import type {
|
||||
AdminProfile,
|
||||
AdminUser,
|
||||
AgentDebugResult,
|
||||
AiLogRecord,
|
||||
ChatDetail,
|
||||
ChatRecord,
|
||||
@@ -40,7 +40,6 @@ const dashboardFilters = reactive({
|
||||
});
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const userKeyword = ref("");
|
||||
const knowledge = ref<KnowledgeItem[]>([]);
|
||||
const models = ref<ModelItem[]>([]);
|
||||
const configs = ref<SystemConfigItem[]>([]);
|
||||
const chats = ref<ChatRecord[]>([]);
|
||||
@@ -50,7 +49,7 @@ const retrievalLogs = ref<RetrievalLogItem[]>([]);
|
||||
const attentionRecords = ref<AttentionItem[]>([]);
|
||||
const selectedRetrievalLog = ref<Record<string, unknown> | null>(null);
|
||||
const retrievalDetailOpen = ref(false);
|
||||
const promptContent = ref("");
|
||||
const previewKnowledgeId = ref<number | null>(null);
|
||||
const chatDetail = ref<ChatDetail | null>(null);
|
||||
const chatDetailOpen = ref(false);
|
||||
const selectedAiLog = ref<AiLogRecord | null>(null);
|
||||
@@ -68,11 +67,6 @@ const editingModelId = ref<number | null>(null);
|
||||
const editingKnowledgeId = ref<number | null>(null);
|
||||
const batchNodeToken = ref("");
|
||||
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({
|
||||
phone: "",
|
||||
@@ -207,18 +201,6 @@ const chatFilters = reactive({
|
||||
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) => {
|
||||
if (activeMenu.value === "records") await loadRecordTab(tab);
|
||||
});
|
||||
@@ -262,8 +244,8 @@ async function switchMenu(menu: string) {
|
||||
}
|
||||
|
||||
async function previewKnowledge(knowledgeId: number) {
|
||||
previewKnowledgeId.value = knowledgeId;
|
||||
await switchMenu("prompt");
|
||||
agentForm.knowledgeIds = [knowledgeId];
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
@@ -282,16 +264,6 @@ async function loadCurrentMenu() {
|
||||
try {
|
||||
if (activeMenu.value === "dashboard") await loadDashboard();
|
||||
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 === "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() {
|
||||
if (!validateModelForm()) return;
|
||||
const payload = buildModelPayload();
|
||||
@@ -743,66 +703,6 @@ function serializeSystemSettingValue(value: SystemSettingValue) {
|
||||
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) {
|
||||
selectedRetrievalLog.value = await api.retrievalLogDetail(row.id);
|
||||
retrievalDetailOpen.value = true;
|
||||
@@ -1026,118 +926,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</template>
|
||||
|
||||
<template v-if="activeMenu === 'prompt'">
|
||||
<div class="page-head inline">
|
||||
<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>
|
||||
<AgentManagementView :preview-knowledge-id="previewKnowledgeId" @consumed-preview="previewKnowledgeId = null" />
|
||||
</template>
|
||||
|
||||
<template v-if="activeMenu === 'models'">
|
||||
|
||||
@@ -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>
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
SystemConfigItem,
|
||||
UserImportResult,
|
||||
PageResult,
|
||||
PromptDetail,
|
||||
PromptHistoryItem,
|
||||
} from "../types/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"),
|
||||
runKnowledgeMigration: (knowledgeId?: number) =>
|
||||
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) =>
|
||||
request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
|
||||
resetPrompt: () => request<{ promptContent: string }>("/admin/prompt/reset", { method: "POST", body: "{}" }),
|
||||
request<PromptDetail>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
|
||||
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>) =>
|
||||
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
|
||||
models: () => request<ModelItem[]>("/admin/model/list"),
|
||||
|
||||
@@ -635,11 +635,175 @@ textarea {
|
||||
|
||||
.agent-workbench {
|
||||
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;
|
||||
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-preview-panel,
|
||||
.agent-trace-panel {
|
||||
@@ -752,6 +916,17 @@ textarea {
|
||||
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 {
|
||||
overflow: hidden;
|
||||
color: #667a73;
|
||||
@@ -1178,7 +1353,7 @@ textarea {
|
||||
|
||||
@media (max-width: 1450px) {
|
||||
.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 {
|
||||
@@ -1205,6 +1380,15 @@ textarea {
|
||||
.agent-trace-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.agent-page-head,
|
||||
.agent-current-summary {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.agent-page-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@@ -1212,6 +1396,19 @@ textarea {
|
||||
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 {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,27 @@ export interface DashboardStats {
|
||||
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 {
|
||||
id: number;
|
||||
phone: string;
|
||||
|
||||
@@ -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")
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.pagination import page_result
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
@@ -32,8 +33,13 @@ DEFAULT_PROMPT = "你是大本营答疑助手。你只能基于提供的知识
|
||||
|
||||
@router.get("/prompt")
|
||||
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))
|
||||
return api_success({"promptContent": prompt.prompt_content if prompt else DEFAULT_PROMPT})
|
||||
row = db.execute(
|
||||
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")
|
||||
@@ -42,22 +48,91 @@ def save_prompt(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> 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.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id)
|
||||
db.commit()
|
||||
return api_success({"promptContent": prompt.prompt_content})
|
||||
db.refresh(prompt)
|
||||
return api_success(_prompt_detail(prompt, current_admin))
|
||||
|
||||
|
||||
@router.post("/prompt/reset")
|
||||
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.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id)
|
||||
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")
|
||||
@@ -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]:
|
||||
query = select(Knowledge).where(Knowledge.status == 1)
|
||||
if knowledge_ids:
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
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 app.models.base import Base
|
||||
@@ -11,9 +11,14 @@ from app.models.base import Base
|
||||
|
||||
class Prompt(Base):
|
||||
__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)
|
||||
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_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
Reference in New Issue
Block a user