feat: add ai prompt release workflow
This commit is contained in:
@@ -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、异常趋势和待处理标签。",
|
||||
|
||||
+47
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+47
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+68
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user