Files
PromptArch/components/SettingsPanel.tsx
uroma b60f67465f feat: Fix SEO agent behavior and add z.ai API validation
- 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 <noreply@anthropic.com>
2026-01-19 19:15:35 +00:00

505 lines
18 KiB
TypeScript

"use client";
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, 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,
apiValidationStatus,
setApiValidationStatus,
} = useStore();
const t = translations[language].settings;
const common = translations[language].common;
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const [isAuthLoading, setIsAuthLoading] = useState(false);
const [validating, setValidating] = useState<Record<string, boolean>>({});
const validationDebounceRef = useRef<Record<string, NodeJS.Timeout>>({});
const handleSave = () => {
if (typeof window !== "undefined") {
localStorage.setItem("promptarch-api-keys", JSON.stringify(apiKeys));
alert(t.keysSaved);
}
};
const handleLoad = () => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("promptarch-api-keys");
if (saved) {
try {
const keys = JSON.parse(saved);
if (keys.qwen) {
setApiKey("qwen", keys.qwen);
modelAdapter.updateQwenApiKey(keys.qwen);
}
if (keys.ollama) {
setApiKey("ollama", keys.ollama);
modelAdapter.updateOllamaApiKey(keys.ollama);
}
if (keys.zai) {
setApiKey("zai", keys.zai);
modelAdapter.updateZaiApiKey(keys.zai);
}
} catch (e) {
console.error("Failed to load API keys:", e);
}
}
const storedTokens = modelAdapter.getQwenTokenInfo();
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);
break;
case "ollama":
modelAdapter.updateOllamaApiKey(value);
break;
case "zai":
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 () => {
if (qwenTokens) {
setQwenTokens(null);
modelAdapter.updateQwenTokens();
modelAdapter.updateQwenApiKey(apiKeys.qwen || "");
setApiValidationStatus("qwen", { valid: false });
return;
}
setIsAuthLoading(true);
try {
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(
error instanceof Error ? error.message : t.qwenAuthFailed
);
} finally {
setIsAuthLoading(false);
}
};
const getStatusIndicator = (provider: "qwen" | "ollama" | "zai") => {
const status = apiValidationStatus[provider];
if (validating[provider]) {
return (
<div className="flex items-center gap-1.5 text-blue-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="text-[9px] font-medium">Validating...</span>
</div>
);
}
if (status?.valid) {
const timeAgo = status.lastValidated
? `Validated ${Math.round((Date.now() - status.lastValidated) / 60000)}m ago`
: "Connected";
return (
<div className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
<CheckCircle className="h-3.5 w-3.5" />
<span className="text-[9px] font-medium">{timeAgo}</span>
</div>
);
}
if (status?.error && apiKeys[provider]?.trim().length > 0) {
return (
<div className="flex items-center gap-1.5 text-red-500">
<XCircle className="h-3.5 w-3.5" />
<span className="text-[9px] font-medium truncate max-w-[150px]" title={status.error}>
{status.error}
</span>
</div>
);
}
return null;
};
useEffect(() => {
handleLoad();
return () => {
// Clear all debounce timeouts on unmount
Object.values(validationDebounceRef.current).forEach(timeout => {
if (timeout) clearTimeout(timeout);
});
};
}, []);
return (
<div className="mx-auto max-w-3xl space-y-4 lg:space-y-6">
<Card>
<CardHeader className="p-4 lg:p-6 text-start">
<CardTitle className="flex items-center gap-2 text-base lg:text-lg">
<Key className="h-4 w-4 lg:h-5 lg:w-5" />
{t.apiKeys}
</CardTitle>
<CardDescription className="text-xs lg:text-sm">
{t.apiKeysDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 lg:space-y-6 p-4 lg:p-6 pt-0 lg:pt-0">
<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" />
Qwen Code API Key
</label>
<div className="relative">
<Input
type={showApiKey.qwen ? "text" : "password"}
placeholder={t.enterKey("Qwen")}
value={apiKeys.qwen || ""}
onChange={(e) => handleApiKeyChange("qwen", 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("qwen")}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowApiKey((prev) => ({ ...prev, qwen: !prev.qwen }))}
>
{showApiKey.qwen ? (
<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 lg:gap-4">
<div className="flex-1 flex flex-col sm:flex-row sm:items-center gap-2">
<p className="text-[10px] lg:text-xs text-muted-foreground">
{t.getApiKey}{" "}
<a
href="https://help.aliyun.com/zh/dashscope/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Alibaba DashScope
</a>
</p>
{apiKeys.qwen && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[9px] lg:text-[10px] w-fit"
onClick={() => validateApiKey("qwen")}
disabled={validating.qwen}
>
<RefreshCw className={`h-2.5 w-2.5 mr-1 ${validating.qwen ? "animate-spin" : ""}`} />
Test
</Button>
)}
</div>
<Button
variant={qwenTokens ? "secondary" : "outline"}
size="sm"
className="h-7 lg:h-8 text-[10px] lg:text-xs w-full sm:w-auto"
onClick={handleQwenAuth}
disabled={isAuthLoading}
>
{isAuthLoading
? t.signingIn
: qwenTokens
? t.logoutQwen
: t.loginQwen}
</Button>
</div>
{qwenTokens && (
<p className="text-[9px] lg:text-[10px] text-green-600 dark:text-green-400 font-medium">
{t.authenticated} ({t.expires}: {new Date(qwenTokens.expiresAt || 0).toLocaleString()})
</p>
)}
</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" />
Ollama Cloud API Key
</label>
<div className="relative">
<Input
type={showApiKey.ollama ? "text" : "password"}
placeholder={t.enterKey("Ollama")}
value={apiKeys.ollama || ""}
onChange={(e) => handleApiKeyChange("ollama", 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("ollama")}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowApiKey((prev) => ({ ...prev, ollama: !prev.ollama }))}
>
{showApiKey.ollama ? (
<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://ollama.com/cloud"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
ollama.com/cloud
</a>
</p>
{apiKeys.ollama && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[9px] lg:text-[10px] w-fit"
onClick={() => validateApiKey("ollama")}
disabled={validating.ollama}
>
<RefreshCw className={`h-2.5 w-2.5 mr-1 ${validating.ollama ? "animate-spin" : ""}`} />
Test
</Button>
)}
</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" />
Z.AI Plan API Key
</label>
<div className="relative">
<Input
type={showApiKey.zai ? "text" : "password"}
placeholder={t.enterKey("Z.AI")}
value={apiKeys.zai || ""}
onChange={(e) => handleApiKeyChange("zai", 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("zai")}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowApiKey((prev) => ({ ...prev, zai: !prev.zai }))}
>
{showApiKey.zai ? (
<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://docs.z.ai"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
docs.z.ai
</a>
</p>
{apiKeys.zai && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[9px] lg:text-[10px] w-fit"
onClick={() => validateApiKey("zai")}
disabled={validating.zai}
>
<RefreshCw className={`h-2.5 w-2.5 mr-1 ${validating.zai ? "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}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4 lg:p-6 text-start">
<CardTitle className="text-base lg:text-lg">{t.defaultProvider}</CardTitle>
<CardDescription className="text-xs lg:text-sm">
{t.defaultProviderDesc}
</CardDescription>
</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) => (
<button
key={provider}
onClick={() => setSelectedProvider(provider)}
className={`flex items-center gap-2 lg:gap-3 rounded-lg border p-3 lg:p-4 text-left transition-colors hover:bg-muted/50 ${selectedProvider === provider
? "border-primary bg-primary/5"
: "border-border"
}`}
>
<div className="flex h-8 w-8 lg:h-10 lg:w-10 items-center justify-center rounded-md bg-primary/10">
<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>
<p className="text-[10px] lg:text-sm text-muted-foreground truncate">
{provider === "qwen" && t.qwenDesc}
{provider === "ollama" && t.ollamaDesc}
{provider === "zai" && t.zaiDesc}
</p>
</div>
{selectedProvider === provider && (
<div className="h-2 w-2 rounded-full bg-primary flex-shrink-0" />
)}
</button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4 lg:p-6 text-start">
<CardTitle className="text-base lg:text-lg">{t.dataPrivacy}</CardTitle>
<CardDescription className="text-xs lg:text-sm">
{t.dataPrivacyTitleDesc}
</CardDescription>
</CardHeader>
<CardContent className="p-4 lg:p-6 pt-0 lg:pt-0">
<div className="rounded-md border bg-muted/30 p-3 lg:p-4 text-start">
<p className="text-xs lg:text-sm">
{t.dataPrivacyDesc}
</p>
</div>
</CardContent>
</Card>
</div>
);
}