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:
@@ -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}
|
||||
|
||||
@@ -1,14 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
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 } from "lucide-react";
|
||||
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 {
|
||||
@@ -34,6 +74,13 @@ export default function 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);
|
||||
@@ -55,6 +102,36 @@ export default function PromptEnhancer() {
|
||||
}
|
||||
}, [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);
|
||||
@@ -86,21 +163,28 @@ export default function PromptEnhancer() {
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[PromptEnhancer] Starting enhancement...", { selectedProvider, selectedModel, hasQwenAuth: modelAdapter.hasQwenAuth() });
|
||||
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);
|
||||
|
||||
console.log("[PromptEnhancer] Enhancement result:", result);
|
||||
const result = await modelAdapter.enhancePrompt(
|
||||
currentPrompt,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
options
|
||||
);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setEnhancedPrompt(result.data);
|
||||
} else {
|
||||
console.error("[PromptEnhancer] Enhancement failed:", result.error);
|
||||
setError(result.error || t.errorEnhance);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[PromptEnhancer] Enhancement error:", err);
|
||||
setError(err instanceof Error ? err.message : t.errorEnhance);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
@@ -119,125 +203,346 @@ export default function PromptEnhancer() {
|
||||
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">
|
||||
<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 lg:pt-0">
|
||||
<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 lg:gap-2">
|
||||
{(["qwen", "ollama", "zai"] as const).map((provider) => (
|
||||
{/* 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
|
||||
key={provider}
|
||||
variant={selectedProvider === provider ? "default" : "outline"}
|
||||
variant={enhanceMode === "quick" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedProvider(provider)}
|
||||
className={cn(
|
||||
"capitalize text-xs lg:text-sm h-8 lg:h-9 px-2.5 lg:px-3",
|
||||
selectedProvider === provider && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
onClick={() => setEnhanceMode("quick")}
|
||||
className="flex-1 text-xs h-8"
|
||||
>
|
||||
{provider === "qwen" ? "Qwen" : provider === "ollama" ? "Ollama" : "Z.AI"}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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 lg:text-sm 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>
|
||||
|
||||
<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-[200px] resize-y text-sm lg:text-base p-3 lg:p-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-2.5 lg:p-3 text-xs lg:text-sm text-destructive">
|
||||
{error}
|
||||
{!apiKeys[selectedProvider] && (
|
||||
<div className="mt-1.5 lg:mt-2 flex items-center gap-2">
|
||||
<Settings className="h-3.5 w-3.5 lg:h-4 lg:w-4" />
|
||||
<span className="text-[10px] lg:text-xs">{common.configApiKey}</span>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleEnhance} disabled={isProcessing || !currentPrompt.trim()} className="flex-1 h-9 lg:h-10 text-xs lg:text-sm">
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 lg:mr-2 h-3.5 w-3.5 lg:h-4 lg:w-4 animate-spin" />
|
||||
{common.generating}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-1.5 lg:mr-2 h-3.5 w-3.5 lg:h-4 lg:w-4" />
|
||||
{t.title}
|
||||
</>
|
||||
{/* 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>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleClear} disabled={isProcessing} className="h-9 lg:h-10 text-xs lg:text-sm px-3">
|
||||
<RefreshCw className="mr-1.5 lg:mr-2 h-3.5 w-3.5 lg:h-4 lg:w-4" />
|
||||
<span className="hidden sm:inline">{t.clear}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Card className={cn("flex flex-col", !enhancedPrompt && "opacity-50")}>
|
||||
{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>
|
||||
{enhancedPrompt && (
|
||||
<Button variant="ghost" size="icon" onClick={handleCopy} className="h-8 w-8 lg:h-9 lg:w-9">
|
||||
{copied ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 lg:h-4 lg:w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 lg:h-4 lg:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<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 lg:pt-0">
|
||||
<CardContent className="p-4 lg:p-6 pt-0">
|
||||
{enhancedPrompt ? (
|
||||
<div className="rounded-md border bg-muted/50 p-3 lg:p-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<pre className="whitespace-pre-wrap text-xs lg:text-sm leading-relaxed">{enhancedPrompt}</pre>
|
||||
<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">
|
||||
|
||||
@@ -54,6 +54,10 @@ export default function SettingsPanel() {
|
||||
setApiKey("zai", keys.zai);
|
||||
modelAdapter.updateZaiApiKey(keys.zai);
|
||||
}
|
||||
if (keys.openrouter) {
|
||||
setApiKey("openrouter", keys.openrouter);
|
||||
modelAdapter.updateOpenRouterApiKey(keys.openrouter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load API keys:", e);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export default function SettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const validateApiKey = async (provider: "qwen" | "ollama" | "zai") => {
|
||||
const validateApiKey = async (provider: "qwen" | "ollama" | "zai" | "openrouter") => {
|
||||
const key = apiKeys[provider];
|
||||
if (!key || key.trim().length === 0) {
|
||||
setApiValidationStatus(provider, { valid: false, error: "API key is required" });
|
||||
@@ -149,6 +153,9 @@ export default function SettingsPanel() {
|
||||
case "zai":
|
||||
modelAdapter.updateZaiApiKey(value);
|
||||
break;
|
||||
case "openrouter":
|
||||
modelAdapter.updateOpenRouterApiKey(value);
|
||||
break;
|
||||
}
|
||||
|
||||
// Debounce validation (500ms)
|
||||
@@ -187,7 +194,7 @@ export default function SettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIndicator = (provider: "qwen" | "ollama" | "zai") => {
|
||||
const getStatusIndicator = (provider: "qwen" | "ollama" | "zai" | "openrouter") => {
|
||||
const status = apiValidationStatus[provider];
|
||||
|
||||
if (validating[provider]) {
|
||||
@@ -439,6 +446,63 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-start">
|
||||
<label className="flex items-center gap-2 text-xs lg:text-sm font-medium">
|
||||
<Server className="h-3.5 w-3.5 lg:h-4 lg:w-4" />
|
||||
OpenRouter API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiKey.openrouter ? "text" : "password"}
|
||||
placeholder={t.enterKey("OpenRouter")}
|
||||
value={apiKeys.openrouter || ""}
|
||||
onChange={(e) => handleApiKeyChange("openrouter", e.target.value)}
|
||||
className="font-mono text-xs lg:text-sm pr-24"
|
||||
/>
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{getStatusIndicator("openrouter")}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setShowApiKey((prev) => ({ ...prev, openrouter: !prev.openrouter }))}
|
||||
>
|
||||
{showApiKey.openrouter ? (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<p className="text-[10px] lg:text-xs text-muted-foreground">
|
||||
{t.getApiKey}{" "}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
</a>
|
||||
</p>
|
||||
{apiKeys.openrouter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[9px] lg:text-[10px] w-fit"
|
||||
onClick={() => validateApiKey("openrouter")}
|
||||
disabled={validating.openrouter}
|
||||
>
|
||||
<RefreshCw className={`h-2.5 w-2.5 mr-1 ${validating.openrouter ? "animate-spin" : ""}`} />
|
||||
Test
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} className="w-full h-9 lg:h-10 text-xs lg:text-sm">
|
||||
<Save className="mr-1.5 lg:mr-2 h-3.5 w-3.5 lg:h-4 lg:w-4" />
|
||||
{t.saveKeys}
|
||||
@@ -455,7 +519,7 @@ export default function SettingsPanel() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 lg:space-y-4 p-4 lg:p-6 pt-0 lg:pt-0">
|
||||
<div className="grid gap-2 lg:gap-3">
|
||||
{(["qwen", "ollama", "zai"] as const).map((provider) => (
|
||||
{(["qwen", "ollama", "zai", "openrouter"] as const).map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
onClick={() => setSelectedProvider(provider)}
|
||||
@@ -468,11 +532,12 @@ export default function SettingsPanel() {
|
||||
<Server className="h-4 w-4 lg:h-5 lg:w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium capitalize text-sm lg:text-base">{provider}</h3>
|
||||
<h3 className="font-medium capitalize text-sm lg:text-base">{provider === "openrouter" ? "OpenRouter" : provider}</h3>
|
||||
<p className="text-[10px] lg:text-sm text-muted-foreground truncate">
|
||||
{provider === "qwen" && t.qwenDesc}
|
||||
{provider === "ollama" && t.ollamaDesc}
|
||||
{provider === "zai" && t.zaiDesc}
|
||||
{provider === "openrouter" && t.openrouterDesc}
|
||||
</p>
|
||||
</div>
|
||||
{selectedProvider === provider && (
|
||||
|
||||
Reference in New Issue
Block a user