feat: add ai prompt release workflow

This commit is contained in:
2026-06-23 09:06:13 +08:00
parent f145a38ee5
commit b2d60871a5
15 changed files with 1062 additions and 6 deletions
@@ -0,0 +1,362 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { FileText, RefreshCcw, RotateCcw, Save, Send } from "lucide-react";
type PromptTemplate = {
id: string;
name: string;
version: string;
content: string;
enabled: boolean;
status: "draft" | "published" | "archived" | string;
rollback_from_version?: string | null;
published_at?: string | null;
updated_at: string;
};
type LoadState =
| { status: "loading" }
| { status: "ready"; templates: PromptTemplate[] }
| { status: "error"; message: string };
type DraftState = {
name: string;
version: string;
content: string;
};
const defaultDraft: DraftState = {
name: "legal_qa.default",
version: "draft",
content: ""
};
const statusLabels: Record<string, string> = {
draft: "草稿",
published: "已发布",
archived: "已归档"
};
const statusTone: Record<string, string> = {
draft: "",
published: "tag-success",
archived: "tag-warning"
};
export default function AiPromptsPage() {
const [state, setState] = useState<LoadState>({ status: "loading" });
const [draft, setDraft] = useState<DraftState>(defaultDraft);
const [selectedName, setSelectedName] = useState(defaultDraft.name);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
async function loadTemplates(
options: { clearMessage?: boolean; preferName?: string } = { clearMessage: true }
) {
setState({ status: "loading" });
if (options.clearMessage !== false) {
setMessage(null);
}
try {
const response = await fetch("/api/ai-platform/prompt-templates", { cache: "no-store" });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
const templates = Array.isArray(payload) ? payload : [];
setState({ status: "ready", templates });
if (templates.length > 0) {
const nextName = options.preferName ?? selectedName;
const nextTemplate =
templates.find((template) => template.name === nextName && template.status === "published") ??
templates.find((template) => template.name === nextName) ??
templates.find((template) => template.status === "published") ??
templates[0];
setDraftFromTemplate(nextTemplate);
}
} catch (error) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Prompt 模板加载失败"
});
}
}
useEffect(() => {
void loadTemplates();
}, []);
const templates = state.status === "ready" ? state.templates : [];
const names = useMemo(() => Array.from(new Set(templates.map((template) => template.name))).sort(), [templates]);
const visibleTemplates = useMemo(
() =>
templates
.filter((template) => template.name === selectedName)
.sort((left, right) => compareDate(right.published_at ?? right.updated_at, left.published_at ?? left.updated_at)),
[selectedName, templates]
);
const publishedTemplate = visibleTemplates.find((template) => template.status === "published");
const validationMessage = useMemo(() => {
if (!draft.name.trim()) {
return "请填写 Prompt 名称。";
}
if (!draft.version.trim()) {
return "请填写版本号。";
}
if (!draft.content.trim()) {
return "请填写 Prompt 内容。";
}
return null;
}, [draft.content, draft.name, draft.version]);
function setDraftFromTemplate(template: PromptTemplate) {
setSelectedName(template.name);
setDraft({
name: template.name,
version: template.version,
content: template.content
});
}
async function saveDraft() {
if (validationMessage) {
setMessage(validationMessage);
return;
}
setBusyAction("save");
setMessage(null);
try {
const response = await fetch("/api/ai-platform/prompt-templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: draft.name.trim(),
version: draft.version.trim(),
content: draft.content,
enabled: false
})
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
setMessage("草稿已保存,发布后才会成为运行版本。");
setSelectedName(payload.name ?? draft.name.trim());
await loadTemplates({ clearMessage: false, preferName: payload.name ?? draft.name.trim() });
} catch (error) {
setMessage(error instanceof Error ? error.message : "Prompt 草稿保存失败");
} finally {
setBusyAction(null);
}
}
async function publishTemplate(template: PromptTemplate) {
setBusyAction(`publish:${template.id}`);
setMessage(null);
try {
const response = await fetch(`/api/ai-platform/prompt-templates/${encodeURIComponent(template.name)}/publish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version: template.version })
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
setMessage(`已发布 ${template.name} / ${template.version}。`);
await loadTemplates({ clearMessage: false, preferName: template.name });
} catch (error) {
setMessage(error instanceof Error ? error.message : "Prompt 发布失败");
} finally {
setBusyAction(null);
}
}
async function rollbackTemplate(template: PromptTemplate) {
setBusyAction(`rollback:${template.id}`);
setMessage(null);
try {
const response = await fetch(`/api/ai-platform/prompt-templates/${encodeURIComponent(template.name)}/rollback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_version: template.version })
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
setMessage(`已从 ${template.version} 回滚并发布新版本。`);
await loadTemplates({ clearMessage: false, preferName: template.name });
} catch (error) {
setMessage(error instanceof Error ? error.message : "Prompt 回滚失败");
} finally {
setBusyAction(null);
}
}
return (
<>
<div className="page-heading">
<div>
<h1>Prompt 中心</h1>
<p>集中管理 AI 中台提示词草稿、发布版本和回滚记录。</p>
</div>
<div className="toolbar toolbar-inline">
<button className="button button-soft" onClick={() => void loadTemplates()}>
<RefreshCcw size={16} />
刷新
</button>
<button className="button button-primary" disabled={busyAction === "save" || Boolean(validationMessage)} onClick={() => void saveDraft()}>
<Save size={16} />
{busyAction === "save" ? "保存中" : "保存草稿"}
</button>
</div>
</div>
<div className="dashboard-grid">
<section className="panel">
<div className="panel-header">
<h2 className="panel-title">
<FileText size={16} />
草稿编辑
</h2>
{publishedTemplate && <span className="tag tag-success">当前运行:{publishedTemplate.version}</span>}
</div>
<div className="panel-body">
<div className="settings-grid">
<label className="setting-field">
<span>Prompt 名称</span>
<input
className="search-input"
value={draft.name}
onChange={(event) => {
setDraft((current) => ({ ...current, name: event.target.value }));
setSelectedName(event.target.value);
}}
placeholder="legal_qa.default"
/>
</label>
<label className="setting-field">
<span>版本号</span>
<input
className="search-input"
value={draft.version}
onChange={(event) => setDraft((current) => ({ ...current, version: event.target.value }))}
placeholder="v2026.06.23"
/>
</label>
</div>
<label className="setting-field prompt-editor-field">
<span>Prompt 内容</span>
<textarea
className="search-input prompt-textarea"
value={draft.content}
onChange={(event) => setDraft((current) => ({ ...current, content: event.target.value }))}
placeholder="写清楚任务、输出结构、引用依据和失败时的降级策略。"
/>
</label>
{(message || validationMessage) && (
<div className={`inline-message ${validationMessage ? "inline-message-error" : ""}`}>
{validationMessage || message}
</div>
)}
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2 className="panel-title">版本列表</h2>
{names.length > 0 && (
<select className="search-input select-input" value={selectedName} onChange={(event) => setSelectedName(event.target.value)}>
{names.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
</div>
<div className="panel-body">
{state.status === "loading" && <div className="empty-state">正在加载 Prompt 版本...</div>}
{state.status === "error" && (
<div className="empty-state empty-state-error">
<strong>无法连接 AI Platform Prompt 接口</strong>
<span>{state.message}</span>
</div>
)}
{state.status === "ready" && visibleTemplates.length === 0 && (
<div className="empty-state">暂无 Prompt 版本。可先保存一个草稿。</div>
)}
{state.status === "ready" && visibleTemplates.length > 0 && (
<div className="prompt-version-list">
{visibleTemplates.map((template) => {
const isPublished = template.status === "published";
return (
<article className="quality-action-item" key={template.id}>
<div className="list-row">
<div className="list-main">
<strong>{template.version}</strong>
<span className="item-meta">
更新:{formatDate(template.updated_at)}
{template.published_at ? ` · 发布:${formatDate(template.published_at)}` : ""}
</span>
</div>
<span className={`tag ${statusTone[template.status] ?? ""}`}>
{statusLabels[template.status] ?? template.status}
</span>
</div>
{template.rollback_from_version && (
<span className="item-meta">回滚来源:{template.rollback_from_version}</span>
)}
<pre className="prompt-preview">{template.content}</pre>
<div className="action-row">
<button className="button button-soft" onClick={() => setDraftFromTemplate(template)}>
编辑副本
</button>
<button
className="button button-primary"
disabled={isPublished || busyAction === `publish:${template.id}`}
onClick={() => void publishTemplate(template)}
>
<Send size={16} />
{busyAction === `publish:${template.id}` ? "发布中" : "发布"}
</button>
<button
className="button button-soft"
disabled={busyAction === `rollback:${template.id}`}
onClick={() => void rollbackTemplate(template)}
>
<RotateCcw size={16} />
{busyAction === `rollback:${template.id}` ? "回滚中" : "回滚发布"}
</button>
</div>
</article>
);
})}
</div>
)}
</div>
</section>
</div>
</>
);
}
function compareDate(left: string, right: string) {
return new Date(left).getTime() - new Date(right).getTime();
}
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
@@ -1,6 +1,6 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { Activity, Bot, Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
import { Activity, Bot, FileText, Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
type AdminSection = {
title: string;
@@ -21,6 +21,12 @@ const adminSections: AdminSection[] = [
icon: SlidersHorizontal,
href: "/admin/ai-quality"
},
{
title: "Prompt 中心",
description: "维护提示词草稿、发布版本、回滚和运行版本边界。",
icon: FileText,
href: "/admin/ai-prompts"
},
{
title: "AI 质量运营",
description: "质量通过率、fallback、异常趋势和待处理标签。",
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function POST(
request: Request,
context: { params: Promise<{ name: string }> }
) {
const { name } = await context.params;
const targetUrl = new URL(
`/api/v1/prompt-templates/${encodeURIComponent(name)}/publish`,
aiPlatformBaseUrl
);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt publish service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt publish service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function POST(
request: Request,
context: { params: Promise<{ name: string }> }
) {
const { name } = await context.params;
const targetUrl = new URL(
`/api/v1/prompt-templates/${encodeURIComponent(name)}/rollback`,
aiPlatformBaseUrl
);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt rollback service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt rollback service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function GET() {
const targetUrl = new URL("/api/v1/prompt-templates", aiPlatformBaseUrl);
try {
const response = await fetch(targetUrl, { cache: "no-store" });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt template service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt template service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
export async function POST(request: Request) {
const targetUrl = new URL("/api/v1/prompt-templates", aiPlatformBaseUrl);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt template service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt template service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -678,6 +678,39 @@ select {
width: 100%;
}
.prompt-editor-field {
margin-top: 14px;
}
.prompt-textarea {
width: 100%;
min-height: 260px;
padding: 12px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
line-height: 1.55;
}
.prompt-version-list {
display: grid;
gap: 12px;
}
.prompt-preview {
max-height: 220px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
color: #24324a;
background: #f8fafc;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.inline-message {
margin-top: 14px;
padding: 10px 12px;