From b60f67465fb6b8ad2261e450eecda77f8a3a7983 Mon Sep 17 00:00:00 2001 From: uroma Date: Mon, 19 Jan 2026 19:14:59 +0000 Subject: [PATCH] feat: Fix SEO agent behavior and add z.ai API validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "SEO-First" mode to prevent unwanted agent switching - SEO agent now stays locked and answers queries through SEO lens - Add [SUGGEST_AGENT:xxx] marker for smart agent suggestions - Add suggestion banner UI with Switch/Dismiss buttons - Prevent auto-switching mid-response - Add validateConnection() method to ZaiPlanService - Add debounced API key validation (500ms) in Settings - Add inline status indicators (valid/validating/error) - Add persistent validation cache (5min) in localStorage - Add "Test Connection" button for manual re-validation - Add clear error messages for auth failures - Add ApiValidationStatus interface - Add apiValidationStatus state for tracking connection states - Add setApiValidationStatus action - Real-time API key validation in Settings panel - Visual status indicators (✓/✗/🔄) - Agent suggestion banner with Switch/Dismiss actions Co-Authored-By: Claude Sonnet 4.5 --- components/AIAssist.tsx | 62 +++++- components/SettingsPanel.tsx | 349 ++++++++++++++++++++++++++-------- lib/services/model-adapter.ts | 8 + lib/services/zai-plan.ts | 33 +++- lib/store.ts | 21 ++ 5 files changed, 388 insertions(+), 85 deletions(-) diff --git a/components/AIAssist.tsx b/components/AIAssist.tsx index 18bc235..ca60e7a 100644 --- a/components/AIAssist.tsx +++ b/components/AIAssist.tsx @@ -320,6 +320,7 @@ function parseStreamingContent(text: string, currentAgent: string) { let preview: PreviewData | null = null; let chatDisplay = text.trim(); let status: string | null = null; + let suggestedAgent: string | null = null; const decodeHtml = (value: string) => value .replace(/</g, "<") @@ -332,7 +333,13 @@ function parseStreamingContent(text: string, currentAgent: string) { return fenced ? fenced[1].trim() : value.trim(); }; - // 1. Detect Agent (be flexible with brackets and keywords like APP/WEB/SEO) + // 1. Detect SUGGEST_AGENT marker (user can choose to switch) + const suggestMatch = text.match(/\[SUGGEST_AGENT:([\w-]+)\]/i); + if (suggestMatch) suggestedAgent = suggestMatch[1].toLowerCase(); + + // 2. Detect Agent (be flexible with brackets and keywords like APP/WEB/SEO) + // NOTE: We only respect agent switches if explicitly requested via [AGENT:xxx] + // Auto-switching is disabled to prevent unwanted agent changes const agentMatch = text.match(/\[+(?:AGENT|content|seo|smm|pm|code|design|web|app):([\w-]+)\]+/i); if (agentMatch) agent = agentMatch[1].toLowerCase(); @@ -353,8 +360,8 @@ function parseStreamingContent(text: string, currentAgent: string) { // 3. Clean display text - hide all tag-like sequences and their partials chatDisplay = text - // Hide complete tags (flexible brackets) - .replace(/\[+(?:AGENT|content|seo|smm|pm|code|design|web|app|PREVIEW|APP|WEB|SEO|CODE|DESIGN|SMM|PM|CONTENT|PREV):?[\w-]*:?[\w-]*\]+/gi, "") + // Hide complete tags (flexible brackets), including SUGGEST_AGENT + .replace(/\[+(?:AGENT|SUGGEST_AGENT|content|seo|smm|pm|code|design|web|app|PREVIEW|APP|WEB|SEO|CODE|DESIGN|SMM|PM|CONTENT|PREV):?[\w-]*:?[\w-]*\]+/gi, "") // Hide content inside preview block (cleanly) .replace(/\[+PREVIEW:[\w-]+:?[\w-]+?\]+[\s\S]*?(?:\[\/(?:PREVIEW|APP|WEB|SEO|CODE|DESIGN|SMM|PM|CONTENT)\]+|$)/gi, "") // Hide closing tags @@ -427,7 +434,7 @@ function parseStreamingContent(text: string, currentAgent: string) { chatDisplay = "Rendering live artifact..."; } - return { chatDisplay, preview, agent, status }; + return { chatDisplay, preview, agent, status, suggestedAgent }; } // --- Main Component --- @@ -471,6 +478,9 @@ export default function AIAssist() { const [viewMode, setViewMode] = useState<"preview" | "code">("preview"); const [abortController, setAbortController] = useState(null); + // Agent suggestion state + const [suggestedAgent, setSuggestedAgent] = useState(null); + // Agentic States const [assistStep, setAssistStep] = useState<"idle" | "plan" | "generating" | "preview">("idle"); const [aiPlan, setAiPlan] = useState(null); @@ -624,10 +634,17 @@ export default function AIAssist() { currentAgent, onChunk: (chunk) => { accumulated += chunk; - const { chatDisplay, preview, agent, status: streamStatus } = parseStreamingContent(accumulated, currentAgent); + const { chatDisplay, preview, agent, status: streamStatus, suggestedAgent: suggested } = parseStreamingContent(accumulated, currentAgent); if (streamStatus) setStatus(streamStatus); + // Update suggested agent state + if (suggested && suggested !== currentAgent) { + setSuggestedAgent(suggested); + } else { + setSuggestedAgent(null); + } + // Only update local state if we're still on the same tab if (activeTabId === requestTabId) { if (preview && JSON.stringify(preview) !== JSON.stringify(lastParsedPreview)) { @@ -883,6 +900,7 @@ export default function AIAssist() { onClick={() => { setCurrentAgent(agent); updateActiveTab({ currentAgent: agent }); + setSuggestedAgent(null); // Clear suggestion on manual switch }} className={cn( "flex items-center gap-2 px-3 py-2 rounded-full text-[11px] font-black uppercase tracking-widest border transition-all", @@ -896,6 +914,40 @@ export default function AIAssist() { ))} + + {/* Agent Suggestion Banner */} + {suggestedAgent && suggestedAgent !== currentAgent && ( +
+
+
+ +
+
+

+ The {t.agents[suggestedAgent as keyof typeof t.agents] || suggestedAgent} agent might be better suited for this task +

+
+ + +
+
+ )}
{/* Qwen Auth Banner */} diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index dcba6d6..d39263c 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -1,20 +1,33 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import useStore from "@/lib/store"; import modelAdapter from "@/lib/services/adapter-instance"; -import { Save, Key, Server, Eye, EyeOff } from "lucide-react"; +import { Save, Key, Server, Eye, EyeOff, CheckCircle, XCircle, Loader2, RefreshCw } from "lucide-react"; import { translations } from "@/lib/i18n/translations"; export default function SettingsPanel() { - const { language, apiKeys, setApiKey, selectedProvider, setSelectedProvider, qwenTokens, setQwenTokens } = useStore(); + const { + language, + apiKeys, + setApiKey, + selectedProvider, + setSelectedProvider, + qwenTokens, + setQwenTokens, + apiValidationStatus, + setApiValidationStatus, + } = useStore(); const t = translations[language].settings; const common = translations[language].common; + const [showApiKey, setShowApiKey] = useState>({}); const [isAuthLoading, setIsAuthLoading] = useState(false); + const [validating, setValidating] = useState>({}); + const validationDebounceRef = useRef>({}); const handleSave = () => { if (typeof window !== "undefined") { @@ -49,12 +62,83 @@ export default function SettingsPanel() { if (storedTokens) { setQwenTokens(storedTokens); } + + // Load validation status + const storedValidation = localStorage.getItem("promptarch-api-validation"); + if (storedValidation) { + try { + const validation = JSON.parse(storedValidation); + // Only use cached validation if it's less than 5 minutes old + const now = Date.now(); + const fiveMinutes = 5 * 60 * 1000; + const entries = Object.entries(validation) as Array<[string, any]>; + for (const [provider, status] of entries) { + if (status.lastValidated && (now - status.lastValidated) < fiveMinutes) { + setApiValidationStatus(provider as any, status); + } + } + } catch (e) { + console.error("Failed to load validation status:", e); + } + } + } + }; + + const validateApiKey = async (provider: "qwen" | "ollama" | "zai") => { + const key = apiKeys[provider]; + if (!key || key.trim().length === 0) { + setApiValidationStatus(provider, { valid: false, error: "API key is required" }); + return; + } + + setValidating((prev) => ({ ...prev, [provider]: true })); + + try { + const result = await modelAdapter.validateConnection(provider); + + if (result.success && result.data?.valid) { + const status = { + valid: true, + lastValidated: Date.now(), + models: result.data.models, + }; + setApiValidationStatus(provider, status); + + // Save to localStorage + if (typeof window !== "undefined") { + const storedValidation = localStorage.getItem("promptarch-api-validation"); + const allValidation = storedValidation ? JSON.parse(storedValidation) : {}; + allValidation[provider] = status; + localStorage.setItem("promptarch-api-validation", JSON.stringify(allValidation)); + } + } else { + const status = { + valid: false, + error: result.error || "Connection failed", + lastValidated: Date.now(), + }; + setApiValidationStatus(provider, status); + } + } catch (error) { + setApiValidationStatus(provider, { + valid: false, + error: error instanceof Error ? error.message : "Validation failed", + lastValidated: Date.now(), + }); + } finally { + setValidating((prev) => ({ ...prev, [provider]: false })); } }; const handleApiKeyChange = (provider: string, value: string) => { setApiKey(provider as "qwen" | "ollama" | "zai", value); + // Clear existing timeout + if (validationDebounceRef.current[provider]) { + clearTimeout(validationDebounceRef.current[provider]); + } + + // Update the service immediately switch (provider) { case "qwen": modelAdapter.updateQwenApiKey(value); @@ -66,6 +150,15 @@ export default function SettingsPanel() { modelAdapter.updateZaiApiKey(value); break; } + + // Debounce validation (500ms) + if (value.trim().length > 0) { + validationDebounceRef.current[provider] = setTimeout(() => { + validateApiKey(provider as "qwen" | "ollama" | "zai"); + }, 500); + } else { + setApiValidationStatus(provider as any, { valid: false }); + } }; const handleQwenAuth = async () => { @@ -73,6 +166,7 @@ export default function SettingsPanel() { setQwenTokens(null); modelAdapter.updateQwenTokens(); modelAdapter.updateQwenApiKey(apiKeys.qwen || ""); + setApiValidationStatus("qwen", { valid: false }); return; } @@ -81,6 +175,8 @@ export default function SettingsPanel() { const token = await modelAdapter.startQwenOAuth(); setQwenTokens(token); modelAdapter.updateQwenTokens(token); + // Validate after OAuth + await validateApiKey("qwen"); } catch (error) { console.error("Qwen OAuth failed", error); window.alert( @@ -91,8 +187,52 @@ export default function SettingsPanel() { } }; + const getStatusIndicator = (provider: "qwen" | "ollama" | "zai") => { + const status = apiValidationStatus[provider]; + + if (validating[provider]) { + return ( +
+ + Validating... +
+ ); + } + + if (status?.valid) { + const timeAgo = status.lastValidated + ? `Validated ${Math.round((Date.now() - status.lastValidated) / 60000)}m ago` + : "Connected"; + return ( +
+ + {timeAgo} +
+ ); + } + + if (status?.error && apiKeys[provider]?.trim().length > 0) { + return ( +
+ + + {status.error} + +
+ ); + } + + return null; + }; + useEffect(() => { handleLoad(); + return () => { + // Clear all debounce timeouts on unmount + Object.values(validationDebounceRef.current).forEach(timeout => { + if (timeout) clearTimeout(timeout); + }); + }; }, []); return ( @@ -119,34 +259,51 @@ export default function SettingsPanel() { placeholder={t.enterKey("Qwen")} value={apiKeys.qwen || ""} onChange={(e) => handleApiKeyChange("qwen", e.target.value)} - className="font-mono text-xs lg:text-sm pr-10" + className="font-mono text-xs lg:text-sm pr-24" /> - -
- + +
+
+

+ {t.getApiKey}{" "} + + Alibaba DashScope + +

+ {apiKeys.qwen && ( + + )} +
+
+ {getStatusIndicator("ollama")} + +
+
+
+

+ {t.getApiKey}{" "} + + ollama.com/cloud + +

+ {apiKeys.ollama && ( + + )}
-

- {t.getApiKey}{" "} - - ollama.com/cloud - -

@@ -219,33 +393,50 @@ export default function SettingsPanel() { placeholder={t.enterKey("Z.AI")} value={apiKeys.zai || ""} onChange={(e) => handleApiKeyChange("zai", e.target.value)} - className="font-mono text-xs lg:text-sm pr-10" + className="font-mono text-xs lg:text-sm pr-24" /> - +
+ {getStatusIndicator("zai")} + +
+
+
+

+ {t.getApiKey}{" "} + + docs.z.ai + +

+ {apiKeys.zai && ( + + )}
-

- {t.getApiKey}{" "} - - docs.z.ai - -