Major changes: - Plan-first workflow: AI generates structured plan before code, with plan review card (Modify Plan / Start Coding / Skip to Code) - Post-coding UX: Preview + Request Modifications buttons after code gen - OpenRouter integration: 4th AI provider with 20+ model support - Enhanced prompt engine: 9 strategies, 11+ intent patterns, modular - PLAN MODE system prompt block in all 4 services - Fixed stale React closure in approveAndGenerate with isApproval flag - Fixed canvas auto-opening during plan phase with wasIdle gate - Updated README, CHANGELOG, .env.example, version bump to 1.3.0
557 lines
22 KiB
TypeScript
557 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import useStore from "@/lib/store";
|
|
import modelAdapter from "@/lib/services/adapter-instance";
|
|
import {
|
|
Sparkles, Copy, RefreshCw, Loader2, CheckCircle2, Settings,
|
|
AlertTriangle, Info, ChevronDown, ChevronUp, Target, Layers,
|
|
Zap, Brain, FileCode, Bot, Search, Image, Code, Globe
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { translations } from "@/lib/i18n/translations";
|
|
import {
|
|
runDiagnostics,
|
|
detectToolCategory,
|
|
selectTemplate,
|
|
generateAnalysisReport,
|
|
estimateTokens,
|
|
type AnalysisReport,
|
|
type DiagnosticResult,
|
|
TOOL_CATEGORIES,
|
|
TEMPLATES,
|
|
type ToolCategory,
|
|
} from "@/lib/enhance-engine";
|
|
|
|
const toolCategoryIcons: Record<string, React.ElementType> = {
|
|
reasoning: Brain,
|
|
thinking: Brain,
|
|
openweight: Zap,
|
|
agentic: Bot,
|
|
ide: Code,
|
|
fullstack: Globe,
|
|
image: Image,
|
|
search: Search,
|
|
};
|
|
|
|
const toolCategoryNames: Record<string, string> = {
|
|
reasoning: "Reasoning LLM",
|
|
thinking: "Thinking LLM",
|
|
openweight: "Open-Weight",
|
|
agentic: "Agentic AI",
|
|
ide: "IDE AI",
|
|
fullstack: "Full-Stack Gen",
|
|
image: "Image AI",
|
|
search: "Search AI",
|
|
};
|
|
|
|
type EnhanceMode = "quick" | "deep";
|
|
|
|
export default function PromptEnhancer() {
|
|
const {
|
|
language,
|
|
currentPrompt,
|
|
enhancedPrompt,
|
|
selectedProvider,
|
|
selectedModels,
|
|
availableModels,
|
|
apiKeys,
|
|
isProcessing,
|
|
error,
|
|
setSelectedProvider,
|
|
setCurrentPrompt,
|
|
setEnhancedPrompt,
|
|
setProcessing,
|
|
setError,
|
|
setAvailableModels,
|
|
setSelectedModel,
|
|
} = useStore();
|
|
|
|
const t = translations[language].promptEnhancer;
|
|
const common = translations[language].common;
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
const [toolCategory, setToolCategory] = useState<string>("reasoning");
|
|
const [templateId, setTemplateId] = useState<string>("RTF");
|
|
const [enhanceMode, setEnhanceMode] = useState<EnhanceMode>("deep");
|
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
const [diagnostics, setDiagnostics] = useState<DiagnosticResult[]>([]);
|
|
const [analysis, setAnalysis] = useState<AnalysisReport | null>(null);
|
|
const [autoDetected, setAutoDetected] = useState(false);
|
|
|
|
const selectedModel = selectedModels[selectedProvider];
|
|
const models = availableModels[selectedProvider] || modelAdapter.getAvailableModels(selectedProvider);
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
loadAvailableModels();
|
|
const saved = localStorage.getItem("promptarch-api-keys");
|
|
if (saved) {
|
|
try {
|
|
const keys = JSON.parse(saved);
|
|
if (keys.qwen) modelAdapter.updateQwenApiKey(keys.qwen);
|
|
if (keys.ollama) modelAdapter.updateOllamaApiKey(keys.ollama);
|
|
if (keys.zai) modelAdapter.updateZaiApiKey(keys.zai);
|
|
} catch (e) {
|
|
console.error("Failed to load API keys:", e);
|
|
}
|
|
}
|
|
}
|
|
}, [selectedProvider]);
|
|
|
|
const analyzePrompt = useCallback((prompt: string) => {
|
|
if (!prompt.trim()) return;
|
|
|
|
const report = generateAnalysisReport(prompt);
|
|
|
|
if (!autoDetected) {
|
|
if (report.suggestedTool) setToolCategory(report.suggestedTool);
|
|
if (report.suggestedTemplate) setTemplateId(report.suggestedTemplate.framework);
|
|
setAutoDetected(true);
|
|
}
|
|
|
|
setDiagnostics(report.diagnostics);
|
|
setAnalysis(report);
|
|
}, [autoDetected]);
|
|
|
|
useEffect(() => {
|
|
if (!currentPrompt.trim()) {
|
|
setDiagnostics([]);
|
|
setAnalysis(null);
|
|
setAutoDetected(false);
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
analyzePrompt(currentPrompt);
|
|
}, 600);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [currentPrompt, analyzePrompt]);
|
|
|
|
const loadAvailableModels = async () => {
|
|
const fallbackModels = modelAdapter.getAvailableModels(selectedProvider);
|
|
setAvailableModels(selectedProvider, fallbackModels);
|
|
|
|
try {
|
|
const result = await modelAdapter.listModels(selectedProvider);
|
|
if (result.success && result.data) {
|
|
setAvailableModels(selectedProvider, result.data[selectedProvider] || fallbackModels);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load models:", error);
|
|
}
|
|
};
|
|
|
|
const handleEnhance = async () => {
|
|
if (!currentPrompt.trim()) {
|
|
setError(t.enterPromptError);
|
|
return;
|
|
}
|
|
|
|
const apiKey = apiKeys[selectedProvider];
|
|
const isQwenOAuth = selectedProvider === "qwen" && modelAdapter.hasQwenAuth();
|
|
|
|
if (!isQwenOAuth && (!apiKey || !apiKey.trim())) {
|
|
setError(`${common.error}: ${common.configApiKey}`);
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
setError(null);
|
|
|
|
const diagnosticsText = enhanceMode === "deep" && diagnostics.length > 0
|
|
? diagnostics.filter(d => d.detected).map(d => `- ${d.pattern.name}: ${d.suggestion}`).join("\n")
|
|
: "";
|
|
|
|
const options = enhanceMode === "deep"
|
|
? { toolCategory, template: templateId.toLowerCase(), diagnostics: diagnosticsText }
|
|
: { toolCategory: "reasoning", template: "rtf", diagnostics: "" };
|
|
|
|
try {
|
|
const result = await modelAdapter.enhancePrompt(
|
|
currentPrompt,
|
|
selectedProvider,
|
|
selectedModel,
|
|
options
|
|
);
|
|
|
|
if (result.success && result.data) {
|
|
setEnhancedPrompt(result.data);
|
|
} else {
|
|
setError(result.error || t.errorEnhance);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t.errorEnhance);
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (enhancedPrompt) {
|
|
await navigator.clipboard.writeText(enhancedPrompt);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setCurrentPrompt("");
|
|
setEnhancedPrompt(null);
|
|
setError(null);
|
|
setDiagnostics([]);
|
|
setAnalysis(null);
|
|
setAutoDetected(false);
|
|
};
|
|
|
|
const criticalCount = diagnostics.filter(d => d.detected && d.severity === "critical").length;
|
|
const warningCount = diagnostics.filter(d => d.detected && d.severity === "warning").length;
|
|
|
|
const toolEntries = Object.entries(TOOL_CATEGORIES) as [ToolCategory, typeof TOOL_CATEGORIES[ToolCategory]][];
|
|
|
|
return (
|
|
<div className="mx-auto grid max-w-7xl gap-4 lg:gap-6 grid-cols-1 lg:grid-cols-2 text-start">
|
|
{/* Left Column */}
|
|
<div className="space-y-4">
|
|
<Card className="h-fit">
|
|
<CardHeader className="p-4 lg:p-6 text-start">
|
|
<CardTitle className="flex items-center gap-2 text-base lg:text-lg">
|
|
<Sparkles className="h-4 w-4 lg:h-5 lg:w-5" />
|
|
{t.title}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs lg:text-sm">
|
|
{t.description}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 lg:space-y-4 p-4 lg:p-6 pt-0">
|
|
{/* Enhancement Mode Toggle */}
|
|
<div className="space-y-2 text-start">
|
|
<label className="text-xs lg:text-sm font-medium">{t.enhanceMode}</label>
|
|
<div className="flex gap-1.5">
|
|
<Button
|
|
variant={enhanceMode === "quick" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setEnhanceMode("quick")}
|
|
className="flex-1 text-xs h-8"
|
|
>
|
|
<Zap className="mr-1.5 h-3.5 w-3.5" />
|
|
{t.quickMode}
|
|
</Button>
|
|
<Button
|
|
variant={enhanceMode === "deep" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setEnhanceMode("deep")}
|
|
className="flex-1 text-xs h-8"
|
|
>
|
|
<Brain className="mr-1.5 h-3.5 w-3.5" />
|
|
{t.deepMode}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Deep Mode Options */}
|
|
{enhanceMode === "deep" && (
|
|
<>
|
|
{/* Target Tool */}
|
|
<div className="space-y-2 text-start">
|
|
<label className="text-xs lg:text-sm font-medium">{t.targetTool}</label>
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{toolEntries.map(([catId, cat]) => {
|
|
const Icon = toolCategoryIcons[catId] || Target;
|
|
return (
|
|
<Button
|
|
key={catId}
|
|
variant={toolCategory === catId ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => { setToolCategory(catId); setAutoDetected(false); }}
|
|
className={cn(
|
|
"justify-start text-xs h-8 px-2",
|
|
toolCategory === catId && "bg-primary text-primary-foreground"
|
|
)}
|
|
>
|
|
<Icon className="mr-1.5 h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate">{toolCategoryNames[catId]}</span>
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Template Framework */}
|
|
<div className="space-y-2 text-start">
|
|
<label className="text-xs lg:text-sm font-medium">{t.templateLabel}</label>
|
|
<select
|
|
value={templateId}
|
|
onChange={(e) => setTemplateId(e.target.value)}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{TEMPLATES.map((tmpl) => (
|
|
<option key={tmpl.framework} value={tmpl.framework}>
|
|
{tmpl.name} — {tmpl.description}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* AI Provider */}
|
|
<div className="space-y-2 text-start">
|
|
<label className="text-xs lg:text-sm font-medium">{common.aiProvider}</label>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{(["qwen", "ollama", "zai", "openrouter"] as const).map((provider) => (
|
|
<Button
|
|
key={provider}
|
|
variant={selectedProvider === provider ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setSelectedProvider(provider)}
|
|
className={cn(
|
|
"capitalize text-xs h-8 px-2.5",
|
|
selectedProvider === provider && "bg-primary text-primary-foreground"
|
|
)}
|
|
>
|
|
{provider === "qwen" ? "Qwen" : provider === "ollama" ? "Ollama" : provider === "zai" ? "Z.AI" : "OpenRouter"}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model */}
|
|
<div className="space-y-2 text-start">
|
|
<label className="text-xs lg:text-sm font-medium">{common.model}</label>
|
|
<select
|
|
value={selectedModel}
|
|
onChange={(e) => setSelectedModel(selectedProvider, e.target.value)}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{models.map((model) => (
|
|
<option key={model} value={model}>
|
|
{model}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Prompt Input */}
|
|
<div className="space-y-2 text-start">
|
|
<label className="text-xs lg:text-sm font-medium">{t.inputLabel}</label>
|
|
<Textarea
|
|
placeholder={t.placeholder}
|
|
value={currentPrompt}
|
|
onChange={(e) => setCurrentPrompt(e.target.value)}
|
|
className="min-h-[150px] lg:min-h-[180px] resize-y text-sm p-3 lg:p-4"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded-md bg-destructive/10 p-2.5 text-xs text-destructive">
|
|
{error}
|
|
{!apiKeys[selectedProvider] && (
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
<Settings className="h-3.5 w-3.5" />
|
|
<span className="text-[10px]">{common.configApiKey}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleEnhance} disabled={isProcessing || !currentPrompt.trim()} className="flex-1 h-9 text-xs">
|
|
{isProcessing ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
{common.generating}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
|
{enhanceMode === "deep" ? t.deepEnhance : t.title}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button variant="outline" onClick={handleClear} disabled={isProcessing} className="h-9 text-xs px-3">
|
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">{t.clear}</span>
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Diagnostics Card */}
|
|
{enhanceMode === "deep" && diagnostics.length > 0 && (
|
|
<Card className="h-fit">
|
|
<CardHeader
|
|
className="p-4 lg:p-6 text-start cursor-pointer select-none"
|
|
onClick={() => setShowDiagnostics(!showDiagnostics)}
|
|
>
|
|
<CardTitle className="flex items-center justify-between text-sm lg:text-base">
|
|
<span className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
{t.diagnosticsTitle}
|
|
{criticalCount > 0 && (
|
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
|
{criticalCount}
|
|
</span>
|
|
)}
|
|
{warningCount > 0 && (
|
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-500 text-[10px] font-bold text-white">
|
|
{warningCount}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{showDiagnostics ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</CardTitle>
|
|
{analysis && !showDiagnostics && (
|
|
<CardDescription className="text-xs">
|
|
{t.promptQuality}: <span className="font-semibold">{analysis.overallScore}/100</span>
|
|
{" — "}
|
|
{analysis.suggestedTool ? toolCategoryNames[analysis.suggestedTool] : "Auto"}
|
|
{" / "}
|
|
{analysis.suggestedTemplate?.name || "RTF"}
|
|
{" — ~"}
|
|
{estimateTokens(currentPrompt)} {t.tokensLabel}
|
|
</CardDescription>
|
|
)}
|
|
</CardHeader>
|
|
{showDiagnostics && (
|
|
<CardContent className="p-4 lg:p-6 pt-0 space-y-2">
|
|
{analysis && (
|
|
<div className="mb-3">
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span>{t.promptQuality}</span>
|
|
<span className="font-semibold">{analysis.overallScore}/100</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
"h-full rounded-full transition-all",
|
|
analysis.overallScore >= 70 ? "bg-green-500" : analysis.overallScore >= 40 ? "bg-amber-500" : "bg-red-500"
|
|
)}
|
|
style={{ width: `${analysis.overallScore}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{diagnostics.filter(d => d.detected).map((d, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"rounded-md p-2 text-xs",
|
|
d.severity === "critical" && "bg-red-500/10 border border-red-500/20",
|
|
d.severity === "warning" && "bg-amber-500/10 border border-amber-500/20",
|
|
d.severity === "info" && "bg-blue-500/10 border border-blue-500/20"
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
{d.severity === "critical" ? (
|
|
<AlertTriangle className="h-3.5 w-3.5 text-red-500 mt-0.5 flex-shrink-0" />
|
|
) : d.severity === "warning" ? (
|
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 mt-0.5 flex-shrink-0" />
|
|
) : (
|
|
<Info className="h-3.5 w-3.5 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
)}
|
|
<div>
|
|
<span className="font-medium">{d.pattern.name}</span>
|
|
<p className="text-muted-foreground mt-0.5">{d.suggestion}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{analysis && analysis.missingDimensions.length > 0 && (
|
|
<div className="mt-3 rounded-md bg-muted/50 p-2.5">
|
|
<p className="text-xs font-medium mb-1.5">{t.missingDimensions}</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{analysis.missingDimensions.map((dim, i) => (
|
|
<span key={i} className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
|
{dim}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{analysis && (
|
|
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
|
<span>~{estimateTokens(currentPrompt)} {t.inputTokens}</span>
|
|
{enhancedPrompt && (
|
|
<>
|
|
<span>→</span>
|
|
<span>~{estimateTokens(enhancedPrompt)} {t.outputTokens}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Column - Output */}
|
|
<Card className={cn("flex flex-col sticky top-4", !enhancedPrompt && "opacity-50")}>
|
|
<CardHeader className="p-4 lg:p-6 text-start">
|
|
<CardTitle className="flex items-center justify-between text-base lg:text-lg">
|
|
<span className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-4 w-4 lg:h-5 lg:w-5 text-green-500" />
|
|
{t.enhancedTitle}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
{enhancedPrompt && (
|
|
<>
|
|
<span className="text-[10px] text-muted-foreground font-mono">
|
|
~{estimateTokens(enhancedPrompt)} tok
|
|
</span>
|
|
<Button variant="ghost" size="icon" onClick={handleCopy} className="h-8 w-8">
|
|
{copied ? (
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardTitle>
|
|
<CardDescription className="text-xs lg:text-sm">
|
|
{t.enhancedDesc}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-4 lg:p-6 pt-0">
|
|
{enhancedPrompt ? (
|
|
<div className="space-y-3">
|
|
{enhanceMode === "deep" && analysis && (
|
|
<div className="rounded-md bg-primary/5 border border-primary/20 p-2.5 text-xs">
|
|
<div className="flex items-center gap-1.5 mb-1 font-medium text-primary">
|
|
<Layers className="h-3.5 w-3.5" />
|
|
{t.strategyNote}
|
|
</div>
|
|
<p className="text-muted-foreground">
|
|
{t.strategyForTool
|
|
.replace("{tool}", analysis.suggestedTool ? toolCategoryNames[analysis.suggestedTool] : "Reasoning LLM")
|
|
.replace("{template}", analysis.suggestedTemplate?.name || "RTF")}
|
|
{criticalCount > 0 && ` ${t.fixedIssues.replace("{count}", String(criticalCount))}`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div className="rounded-md border bg-muted/50 p-3 lg:p-4 animate-in fade-in slide-in-from-bottom-2 duration-300 max-h-[60vh] overflow-y-auto">
|
|
<pre className="whitespace-pre-wrap text-xs lg:text-sm leading-relaxed">{enhancedPrompt}</pre>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-[150px] lg:h-[200px] items-center justify-center text-center text-xs lg:text-sm text-muted-foreground italic">
|
|
{t.emptyState}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|