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 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'">

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,
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"),

View File

@@ -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;
}

View File

@@ -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;