feat: v1.3.0 — plan-first workflow, OpenRouter provider, enhanced prompt engine

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
This commit is contained in:
admin
2026-03-18 18:45:37 +00:00
Unverified
parent cca11fe07a
commit a4b7a0d9e4
17 changed files with 3189 additions and 358 deletions

View File

@@ -366,6 +366,9 @@ function parseStreamingContent(text: string, currentAgent: string) {
.replace(/\[+PREVIEW:[\w-]+:?[\w-]+?\]+[\s\S]*?(?:\[\/(?:PREVIEW|APP|WEB|SEO|CODE|DESIGN|SMM|PM|CONTENT)\]+|$)/gi, "")
// Hide closing tags
.replace(/\[\/(?:PREVIEW|APP|WEB|SEO|CODE|DESIGN|SMM|PM|CONTENT)\]+/gi, "")
// Hide PLAN tags from chat display
.replace(/\[PLAN\][\s\S]*?\[\/PLAN\]/gi, "")
.replace(/\[PLAN\][\s\S]*?$/gi, "")
// Hide ANY partial tag sequence at the very end (greedy)
.replace(/\[+[^\]]*$/g, "")
.trim();
@@ -437,6 +440,41 @@ function parseStreamingContent(text: string, currentAgent: string) {
return { chatDisplay, preview, agent, status, suggestedAgent };
}
// --- Plan Parser ---
function parsePlanFromResponse(text: string): { plan: Record<string, any> | null; cleanedText: string } {
let plan: Record<string, any> | null = null;
let cleanedText = text;
const planTagMatch = text.match(/\[PLAN\]([\s\S]*?)\[\/PLAN\]/i);
if (planTagMatch) {
try {
const d = JSON.parse(planTagMatch[1].trim());
plan = { rawText: d.summary || d.description || "", architecture: d.architecture || "", techStack: Array.isArray(d.techStack) ? d.techStack : [], files: Array.isArray(d.files) ? d.files : [], steps: Array.isArray(d.steps) ? d.steps : [] };
cleanedText = text.replace(/\[PLAN\][\s\S]*?\[\/PLAN\]/i, "").trim();
return { plan, cleanedText };
} catch (e) { /* not JSON */ }
}
const pLines = text.split("\n");
let arch = "", stack: string[] = [], filesL: string[] = [], stepsL: string[] = [], summaryL: string[] = [];
let section = "";
for (const ln of pLines) {
const s = ln.trim();
if (/^#+\s*(?:technical\s*)?architecture/i.test(s)) { section = "arch"; continue; }
if (/^#+\s*(?:tech\s*stack|technologies|frameworks)/i.test(s)) { section = "stack"; continue; }
if (/^#+\s*(?:files|modules|components|pages)/i.test(s)) { section = "files"; continue; }
if (/^#+\s*(?:steps|implementation|tasks|action\s*plan|timeline)/i.test(s)) { section = "steps"; continue; }
if (/^#+/.test(s)) { if (section && section !== "summary") section = "summary"; continue; }
if (section === "arch" && s) arch += s + " ";
else if (section === "stack") { const m = s.match(/^[-*`\s]*(.+)/); if (m) stack.push(m[1].replace(/[`*_]/g, "").trim()); }
else if (section === "files") { const m = s.match(/^[-*]\s*(.+)/); if (m) filesL.push(m[1].replace(/[`*_]/g, "").trim()); }
else if (section === "steps") { const m = s.match(/\d+\.\s*(.+)/) || s.match(/^[-*]\s*(.+)/); if (m) stepsL.push(m[1].replace(/[`*_]/g, "").trim()); }
else if (!section && s && !/^#/.test(s)) summaryL.push(s);
}
if (arch || stack.length || filesL.length || stepsL.length) {
plan = { rawText: summaryL.slice(0, 10).join("\n").trim(), architecture: arch.trim(), techStack: stack, files: filesL, steps: stepsL };
}
return { plan, cleanedText };
}
// --- Main Component ---
export default function AIAssist() {
@@ -572,7 +610,7 @@ export default function AIAssist() {
loadModels();
}, [selectedProvider, selectedModels, setSelectedModel]);
const handleSendMessage = async (e?: React.FormEvent, forcedPrompt?: string) => {
const handleSendMessage = async (e?: React.FormEvent, forcedPrompt?: string, isApproval?: boolean) => {
if (e) e.preventDefault();
const finalInput = forcedPrompt || input;
if (!finalInput.trim() || isProcessing) return;
@@ -596,6 +634,8 @@ export default function AIAssist() {
setInput("");
}
// Capture whether this is the initial plan phase (before any code generation)
const wasIdle = !isApproval && (assistStep === "idle" || assistStep === "plan");
setIsProcessing(true);
if (assistStep === "idle") setAssistStep("plan");
@@ -650,8 +690,11 @@ export default function AIAssist() {
if (preview && JSON.stringify(preview) !== JSON.stringify(lastParsedPreview)) {
setPreviewData(preview);
lastParsedPreview = preview;
setShowCanvas(true);
if (isPreviewRenderable(preview)) setViewMode("preview");
// Only show canvas if NOT in initial plan phase
if (!wasIdle) {
setShowCanvas(true);
if (isPreviewRenderable(preview)) setViewMode("preview");
}
}
if (agent !== currentAgent) {
@@ -672,7 +715,7 @@ export default function AIAssist() {
history: [...updatedHistory.slice(0, -1), lastMsg],
previewData: preview || undefined,
currentAgent: agent,
showCanvas: !!preview
showCanvas: !!preview && !wasIdle
});
},
signal: controller.signal
@@ -683,10 +726,22 @@ export default function AIAssist() {
if (!response.success) throw new Error(response.error);
if (assistStep === "plan" || assistStep === "idle") {
// When this was the initial request from idle/plan, ALWAYS show plan card
if (wasIdle) {
setAssistStep("plan");
} else {
const { plan: parsedPlan } = parsePlanFromResponse(accumulated);
if (parsedPlan) {
setAiPlan(parsedPlan);
} else {
setAiPlan({ rawText: accumulated, architecture: "", techStack: [], files: [], steps: [] });
}
} else if ((lastParsedPreview as PreviewData | null)?.data) {
// After approval: show the generated preview
setAssistStep("preview");
setShowCanvas(true);
if (isPreviewRenderable(lastParsedPreview)) setViewMode("preview");
} else {
setAssistStep("idle");
}
} catch (error) {
@@ -703,7 +758,7 @@ export default function AIAssist() {
const approveAndGenerate = () => {
setAssistStep("generating");
handleSendMessage(undefined, "Approved. Please generate the code according to the plan.");
handleSendMessage(undefined, "Approved. Please generate the code according to the plan.", true);
};
const stopGeneration = () => {
@@ -793,7 +848,7 @@ export default function AIAssist() {
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-1.5 p-1 bg-blue-50/50 dark:bg-blue-900/20 rounded-xl border border-blue-100/50 dark:border-blue-900/50">
{(["qwen", "ollama", "zai"] as const).map((provider) => (
{(["qwen", "ollama", "zai", "openrouter"] as const).map((provider) => (
<button
key={provider}
onClick={() => setSelectedProvider(provider)}
@@ -804,7 +859,7 @@ export default function AIAssist() {
: "text-slate-400 hover:text-blue-500 hover:bg-blue-50 dark:text-blue-200/40 dark:hover:text-blue-200"
)}
>
{(provider === "qwen" ? "Qwen" : provider === "ollama" ? "Ollama" : "Z.AI")}
{(provider === "qwen" ? "Qwen" : provider === "ollama" ? "Ollama" : provider === "openrouter" ? "OpenRouter" : "Z.AI")}
</button>
))}
</div>
@@ -1060,6 +1115,12 @@ export default function AIAssist() {
</h3>
<div className="space-y-4">
<div>
{aiPlan.rawText && (
<div className="mb-4 p-3 rounded-xl bg-slate-500/5 border border-slate-500/10 max-h-[200px] overflow-y-auto">
<p className="text-[11px] font-bold text-slate-500 uppercase mb-2">{t.planSummary}</p>
<p className="text-xs text-slate-400 leading-relaxed">{aiPlan.rawText}</p>
</div>
)}
<p className="text-[11px] font-bold text-slate-500 uppercase mb-1">{t.architecture}</p>
<p className="text-xs text-slate-400">{aiPlan.architecture}</p>
</div>
@@ -1077,12 +1138,30 @@ export default function AIAssist() {
<p className="text-[10px] text-slate-400">{t.filesPlanned(aiPlan.files?.length || 0)}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-4">
<Button
onClick={() => { setAiPlan(null); setAssistStep("idle"); setInput("Modify this plan: "); setTimeout(() => { const el = document.querySelector<HTMLInputElement>(`[data-ai-input]`); if (el) el.focus(); }, 100); }}
disabled={isProcessing}
variant="outline"
className="bg-slate-500/10 hover:bg-slate-500/20 border-slate-500/20 text-slate-300 font-black uppercase text-[10px] tracking-widest py-4 rounded-xl"
>
<LayoutPanelLeft className="h-3.5 w-3.5 mr-1.5" /> {t.modifyPlan}
</Button>
<Button
onClick={approveAndGenerate}
disabled={isProcessing}
className="bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[10px] tracking-widest py-4 rounded-xl shadow-lg shadow-blue-500/20"
>
{isProcessing ? t.startingEngine : t.startCoding}
</Button>
</div>
<Button
onClick={approveAndGenerate}
onClick={() => { setAiPlan(null); setAssistStep("idle"); }}
disabled={isProcessing}
className="w-full mt-4 bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[10px] tracking-widest py-5 rounded-xl shadow-lg shadow-blue-500/20"
variant="ghost"
className="w-full mt-2 text-slate-500 hover:text-slate-300 font-bold uppercase text-[9px] tracking-widest py-3 rounded-xl"
>
{isProcessing ? t.startingEngine : t.approveGenerate}
{t.skipPlan}
</Button>
</div>
</div>
@@ -1103,6 +1182,25 @@ export default function AIAssist() {
<Zap className="h-3.5 w-3.5 mr-2" /> {t.activateArtifact}
</Button>
)}
{/* Post-coding action buttons */}
{msg.role === "assistant" && assistStep === "preview" && i === aiAssistHistory.length - 1 && !isProcessing && (
<div className="mt-4 grid grid-cols-2 gap-2 animate-in zoom-in-95 duration-300">
<Button
onClick={() => { setShowCanvas(true); setViewMode(isPreviewRenderable(previewData as PreviewData) ? "preview" : "code"); }}
className="bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[10px] tracking-widest py-4 rounded-xl shadow-lg shadow-blue-500/20"
>
<Zap className="h-3.5 w-3.5 mr-1.5" /> {t.activateArtifact}
</Button>
<Button
onClick={() => { setAssistStep("idle"); setInput("Modify this: "); setTimeout(() => { const el = document.querySelector<HTMLInputElement>(`[data-ai-input]`); if (el) el.focus(); }, 100); }}
variant="outline"
className="bg-slate-500/10 hover:bg-slate-500/20 border-slate-500/20 text-slate-300 font-black uppercase text-[10px] tracking-widest py-4 rounded-xl"
>
<LayoutPanelLeft className="h-3.5 w-3.5 mr-1.5" /> Request Modifications
</Button>
</div>
)}
</div>
{msg.role === "assistant" && isProcessing && i === aiAssistHistory.length - 1 && status && (
@@ -1137,7 +1235,7 @@ export default function AIAssist() {
<form onSubmit={handleSendMessage} className="relative group">
<div className="absolute inset-0 bg-blue-500/5 rounded-[1.5rem] blur-xl group-focus-within:bg-blue-500/10 transition-all" />
<Input
value={input}
data-ai-input="" value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.placeholder}
disabled={isProcessing}