Add prompt improvement button in drawer

This commit is contained in:
Xin Wang
2026-08-26 12:18:18 +08:00
parent 239b9027ca
commit ab409ba9c0
13 changed files with 2067 additions and 39 deletions
@@ -17,6 +17,7 @@ import {
FlaskConical,
Loader2,
Play,
Sparkles,
Square,
TriangleAlert,
Wrench,
@@ -27,6 +28,11 @@ import {
DebugTimeline,
type DebugTimelineItem,
} from "@/components/debug-timeline";
import {
PromptImprovementPanel,
type PromptImprovementEditor,
type PromptImprovementState,
} from "@/components/assistant-editor/prompt-improvement-panel";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -339,10 +345,16 @@ export function AutoTestLivePanel({
testCase,
state,
onStart,
improvement,
promptEditor,
onImprove,
}: {
testCase: TestCase | null;
state: AutoTestRunState;
onStart: () => void;
improvement: PromptImprovementState;
promptEditor?: PromptImprovementEditor;
onImprove: () => void;
}) {
if (!testCase) {
return (
@@ -445,6 +457,8 @@ export function AutoTestLivePanel({
`${turn.id}:${turn.runtimeRecords?.length ?? 0}:${turn.toolCalls.length}:${turn.evaluations.length}`,
),
state.runCase?.overallCriteria.length ?? 0,
improvement.phase,
improvement.phase === "ready" ? improvement.result.diagnosis : "",
].join("|");
const autoFollowResetKey = state.run?.id ?? state.selectedId ?? "auto-test";
@@ -524,6 +538,14 @@ export function AutoTestLivePanel({
))}
</div>
)}
{state.phase === "failed" && promptEditor && improvement.phase !== "idle" && (
<PromptImprovementPanel
state={improvement}
editor={promptEditor}
onRegenerate={onImprove}
/>
)}
</div>
</AutoFollowViewport>
);
@@ -864,6 +886,9 @@ export function AutoTestRunBar({
onClearCase,
onStop,
onRerun,
improvement,
canImprovePrompt,
onImprovePrompt,
}: {
state: AutoTestRunState;
testCase: TestCase | null;
@@ -874,6 +899,9 @@ export function AutoTestRunBar({
onClearCase: () => void;
onStop: () => void;
onRerun: () => void;
improvement: PromptImprovementState;
canImprovePrompt: boolean;
onImprovePrompt: () => void;
}) {
const pickerTrigger = (
<button
@@ -926,6 +954,59 @@ export function AutoTestRunBar({
);
}
if (state.phase === "failed" && canImprovePrompt) {
return (
<div className="space-y-2 rounded-[1.4rem] border border-hairline-strong bg-background px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-destructive/15 text-destructive">
<X size={12} />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{testCase?.name}
</span>
<span className="shrink-0 text-xs font-medium text-destructive">
FAIL
</span>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
className="h-8 min-w-0 flex-1 gap-1.5 rounded-full px-3 text-xs"
disabled={
improvement.phase === "generating" ||
Boolean(state.blockedMessage)
}
onClick={onImprovePrompt}
title={state.blockedMessage || "分析失败并生成 Prompt 修改建议"}
>
{improvement.phase === "generating" ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Sparkles size={12} />
)}
{improvement.phase === "generating"
? "正在分析"
: improvement.phase === "ready"
? "重新分析"
: "分析并改进 Prompt"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 shrink-0 rounded-full px-3 text-xs"
disabled={Boolean(state.blockedMessage)}
onClick={onRerun}
title={state.blockedMessage || "再次运行"}
>
再次运行
</Button>
</div>
</div>
);
}
if (
state.phase === "passed" ||
state.phase === "failed" ||
@@ -1038,6 +1119,10 @@ export function useAutoTestRunner({
const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false);
const [runError, setRunError] = useState("");
const [improvement, setImprovement] = useState<PromptImprovementState>({
phase: "idle",
});
const improvementRequestIdRef = useRef(0);
useEffect(() => {
activeRunRef.current = run;
@@ -1188,6 +1273,8 @@ export function useAutoTestRunner({
setRunError("");
setStarting(false);
setStopping(false);
improvementRequestIdRef.current += 1;
setImprovement({ phase: "idle" });
},
[run],
);
@@ -1199,6 +1286,8 @@ export function useAutoTestRunner({
setRunError("");
setStarting(false);
setStopping(false);
improvementRequestIdRef.current += 1;
setImprovement({ phase: "idle" });
}, [run]);
const start = useCallback(async () => {
@@ -1214,6 +1303,8 @@ export function useAutoTestRunner({
setStarting(true);
setRun(null);
setRunError("");
improvementRequestIdRef.current += 1;
setImprovement({ phase: "idle" });
try {
const snapshot = await batchRunsApi.create({
assistantId,
@@ -1251,14 +1342,39 @@ export function useAutoTestRunner({
}
setRun(null);
setRunError("");
improvementRequestIdRef.current += 1;
setImprovement({ phase: "idle" });
}, [cancelActiveRun, run]);
const rerun = useCallback(() => {
setRun(null);
setRunError("");
improvementRequestIdRef.current += 1;
setImprovement({ phase: "idle" });
window.setTimeout(() => void start(), 0);
}, [start]);
const improvePrompt = useCallback(async () => {
if (!run || runCase?.status !== "fail" || improvement.phase === "generating") {
return;
}
const requestId = improvementRequestIdRef.current + 1;
improvementRequestIdRef.current = requestId;
setImprovement({ phase: "generating" });
try {
const result = await batchRunsApi.improvePrompt(run.id);
if (improvementRequestIdRef.current !== requestId) return;
setImprovement({ phase: "ready", result });
} catch (error) {
if (improvementRequestIdRef.current !== requestId) return;
setImprovement({
phase: "error",
message:
error instanceof Error ? error.message : "生成 Prompt 改进建议失败",
});
}
}, [improvement.phase, run, runCase?.status]);
return {
state,
suites,
@@ -1270,5 +1386,7 @@ export function useAutoTestRunner({
start: () => void start(),
stop,
rerun,
improvement,
improvePrompt: () => void improvePrompt(),
};
}
@@ -29,6 +29,7 @@ import {
DebugModeTabs,
useAutoTestRunner,
} from "@/components/assistant-editor/debug-auto-test";
import type { PromptImprovementEditor } from "@/components/assistant-editor/prompt-improvement-panel";
import {
DebugTimeline,
type DebugTimelineItem,
@@ -176,6 +177,7 @@ export function DebugDrawer({
vision = false,
dynamicVariablesEnabled = false,
dynamicVariableDefinitions = {},
promptImprovementEditor,
}: {
assistantId: string | null;
overlay?: boolean;
@@ -185,6 +187,7 @@ export function DebugDrawer({
vision?: boolean;
dynamicVariablesEnabled?: boolean;
dynamicVariableDefinitions?: Record<string, DynamicVariableDefinition>;
promptImprovementEditor?: PromptImprovementEditor;
}) {
const preview = useVoicePreview(assistantId, onNodeActive);
const camera = useCameraPreview();
@@ -347,7 +350,10 @@ export function DebugDrawer({
)}
</div>
{debugMode === "auto" ? (
<AutoTestPanel autoTest={autoTest} />
<AutoTestPanel
autoTest={autoTest}
promptImprovementEditor={promptImprovementEditor}
/>
) : (
<DebugVoicePanel
view={view}
@@ -369,8 +375,10 @@ export function DebugDrawer({
function AutoTestPanel({
autoTest,
promptImprovementEditor,
}: {
autoTest: ReturnType<typeof useAutoTestRunner>;
promptImprovementEditor?: PromptImprovementEditor;
}) {
const {
state,
@@ -383,6 +391,8 @@ function AutoTestPanel({
suites,
cases,
catalogLoading,
improvement,
improvePrompt,
} = autoTest;
return (
@@ -392,6 +402,9 @@ function AutoTestPanel({
testCase={selectedCase}
state={state}
onStart={start}
improvement={improvement}
promptEditor={promptImprovementEditor}
onImprove={improvePrompt}
/>
</div>
<div className="shrink-0 border-t border-hairline bg-card p-3">
@@ -405,6 +418,9 @@ function AutoTestPanel({
onClearCase={clearCase}
onStop={stop}
onRerun={rerun}
improvement={improvement}
canImprovePrompt={Boolean(promptImprovementEditor)}
onImprovePrompt={improvePrompt}
/>
</div>
</div>
@@ -984,6 +984,10 @@ export function PromptEditor({
vision={form.visionEnabled}
dynamicVariablesEnabled
dynamicVariableDefinitions={dynamicVariableDefinitions}
promptImprovementEditor={{
prompt: form.prompt,
onChange: (prompt) => updateForm("prompt", prompt),
}}
/>
)}
</div>
@@ -0,0 +1,263 @@
"use client";
import { Loader2, RotateCcw, Sparkles, TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { PromptImprovementResult } from "@/data/batch-run";
import { cn } from "@/lib/utils";
export type PromptImprovementEditor = {
prompt: string;
onChange: (prompt: string) => void;
};
export type PromptImprovementState =
| { phase: "idle" }
| { phase: "generating" }
| { phase: "ready"; result: PromptImprovementResult }
| { phase: "error"; message: string };
const MANUAL_RECOMMENDATION_LABEL: Record<
PromptImprovementResult["manualRecommendations"][number]["category"],
string
> = {
tool: "工具配置",
test_case: "测试用例",
other: "其他建议",
};
export function PromptImprovementPanel({
state,
editor,
onRegenerate,
}: {
state: PromptImprovementState;
editor: PromptImprovementEditor;
onRegenerate: () => void;
}) {
if (state.phase === "generating") {
return (
<div
className="rounded-2xl border border-hairline bg-surface-card p-4"
aria-live="polite"
>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-surface-strong text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
</span>
正在分析失败轨迹…
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
评估模型正在对照 Prompt、工具设置和失败断言生成最小修改建议。
</p>
</div>
);
}
if (state.phase === "error") {
return (
<div
className="rounded-2xl border border-hairline bg-surface-card p-4"
role="alert"
>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-surface-strong text-muted-foreground">
<TriangleAlert size={14} />
</span>
无法生成改进建议
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{state.message}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 h-8 rounded-full px-3 text-xs"
onClick={onRegenerate}
>
重试分析
</Button>
</div>
);
}
if (state.phase !== "ready") return null;
const { result } = state;
const change = result.promptChange;
const isApplied = Boolean(change && editor.prompt === change.after);
const canApply = Boolean(change && editor.prompt === change.before);
const hasConflict = Boolean(change && !isApplied && !canApply);
return (
<section className="rounded-2xl border border-hairline bg-surface-card p-4 shadow-sm">
<div className="flex items-start gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Sparkles size={15} />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">
Prompt 改进建议
</div>
<div className="mt-0.5 text-[11px] text-muted-soft">
由 {result.evaluatorName} 根据本次失败生成
</div>
</div>
</div>
<p className="mt-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{result.diagnosis}
</p>
{change ? (
<div className="mt-3 rounded-xl border border-hairline bg-canvas-soft/60 p-3">
<div className="text-xs font-medium text-foreground">
建议修改 Prompt
</div>
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
{change.reason}
</p>
{change.evidence.length > 0 && (
<div className="mt-2 space-y-1 border-l border-hairline-strong pl-2.5">
{change.evidence.map((evidence, index) => (
<p
key={`${index}-${evidence}`}
className="text-[11px] leading-4 text-muted-soft"
>
{evidence}
</p>
))}
</div>
)}
<details className="group mt-3 rounded-lg border border-hairline bg-background/70">
<summary className="cursor-pointer list-none px-3 py-2 text-[11px] font-medium text-foreground">
查看修改内容
<span className="ml-1 text-muted-soft transition-colors group-open:text-foreground">
· 修改前 / 修改后
</span>
</summary>
<div className="space-y-2 border-t border-hairline p-2.5">
<PromptTextPreview label="当前 Prompt" value={change.before} />
<PromptTextPreview
label="建议 Prompt"
value={change.after}
strong
/>
</div>
</details>
{hasConflict && (
<p className="mt-2 rounded-lg border border-hairline bg-background/70 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
当前 Prompt 已在测试后发生变化。为避免覆盖新编辑,请保存并重新测试后再生成建议。
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
{isApplied ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 rounded-full px-3 text-xs"
onClick={() => {
if (editor.prompt === change.after) {
editor.onChange(change.before);
}
}}
>
<RotateCcw size={12} />
撤销应用
</Button>
) : (
<Button
type="button"
size="sm"
className="h-8 rounded-full px-3 text-xs"
disabled={!canApply}
onClick={() => {
if (editor.prompt === change.before) {
editor.onChange(change.after);
}
}}
>
应用修改
</Button>
)}
{isApplied && (
<span className="text-[11px] text-muted-foreground">
已写入编辑器,请检查并保存
</span>
)}
</div>
</div>
) : (
<p className="mt-3 rounded-xl border border-hairline bg-canvas-soft/60 px-3 py-2.5 text-xs leading-5 text-muted-foreground">
本次失败不适合通过修改 Prompt 解决,请按下面的建议检查配置。
</p>
)}
{result.manualRecommendations.length > 0 && (
<div className="mt-3 space-y-2 border-t border-hairline pt-3">
<div className="text-[11px] font-medium text-muted-soft">需手动处理</div>
{result.manualRecommendations.map((item, index) => (
<div
key={`${item.category}-${index}-${item.title}`}
className="rounded-xl border border-hairline bg-canvas-soft/50 px-3 py-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-foreground">
{item.title}
</span>
<span className="rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
{MANUAL_RECOMMENDATION_LABEL[item.category]}
</span>
</div>
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
{item.detail}
</p>
</div>
))}
</div>
)}
{!isApplied && !hasConflict && (
<button
type="button"
className="mt-3 text-[11px] text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
onClick={onRegenerate}
>
重新生成建议
</button>
)}
</section>
);
}
function PromptTextPreview({
label,
value,
strong = false,
}: {
label: string;
value: string;
strong?: boolean;
}) {
return (
<div>
<div className="mb-1 text-[10px] font-medium tracking-wide text-muted-soft uppercase">
{label}
</div>
<pre
className={cn(
"scrollbar-subtle max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-hairline p-2.5 font-mono text-[10px] leading-4 text-muted-foreground",
strong ? "bg-surface-strong/65" : "bg-canvas-soft/60",
)}
>
{value || "(空 Prompt)"}
</pre>
</div>
);
}
+16
View File
@@ -126,6 +126,22 @@ export type BatchRunSnapshot = {
stopReason: "manual" | "assertion_failure" | "execution_error" | null;
};
export type PromptImprovementResult = {
diagnosis: string;
promptChange: {
before: string;
after: string;
reason: string;
evidence: string[];
} | null;
manualRecommendations: Array<{
category: "tool" | "test_case" | "other";
title: string;
detail: string;
}>;
evaluatorName: string;
};
export function firstEvaluationFailureReason(item: BatchRunCase): string {
const evaluations = [
...item.turns.flatMap((turn) => turn.evaluations),
+9 -1
View File
@@ -6,7 +6,10 @@
*/
import { loginPathWithReturnTo } from "@/lib/auth-redirect";
import type { BatchRunSnapshot } from "@/data/batch-run";
import type {
BatchRunSnapshot,
PromptImprovementResult,
} from "@/data/batch-run";
import type {
ContextTurn,
FixedInputTurn,
@@ -958,4 +961,9 @@ export const batchRunsApi = {
request<BatchRunSnapshot>(`/api/test-runs/${id}/cancel`, {
method: "POST",
}),
improvePrompt: (id: string) =>
request<PromptImprovementResult>(
`/api/test-runs/${id}/prompt-improvement`,
{ method: "POST" },
),
};