This commit is contained in:
MarkLo 2025-12-06 01:50:41 +08:00
parent 5dee4b323c
commit a0e4365fc3
5 changed files with 466 additions and 295 deletions

View File

@ -3,12 +3,14 @@
*/
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { format } from "date-fns";
import { CheckIcon } from "lucide-react";
import { getApiSettings } from "@/lib/storage";
import { getBaseUrlForModel, getApiKeyForModel } from "@/lib/api-helpers";
import { cn } from "@/lib/utils";
@ -51,7 +53,7 @@ const formSchema = z.object({
quick_think_llm: z.string().min(1, "請選擇快速思維模型"),
deep_think_llm: z.string().min(1, "請選擇深層思維模型"),
// API Configuration
// API Configuration (hidden from UI, populated from localStorage)
quick_think_base_url: z
.string()
.url("請輸入有效的 URL")
@ -105,6 +107,24 @@ export function AnalysisForm({ onSubmit, loading = false }: AnalysisFormProps) {
},
});
// Load API settings from localStorage and update when models change
useEffect(() => {
const savedSettings = getApiSettings();
const quickThinkLlm = form.getValues("quick_think_llm");
const deepThinkLlm = form.getValues("deep_think_llm");
// Set base URLs based on selected models
form.setValue("quick_think_base_url", getBaseUrlForModel(quickThinkLlm));
form.setValue("deep_think_base_url", getBaseUrlForModel(deepThinkLlm));
form.setValue("embedding_base_url", "https://api.openai.com/v1");
// Set API keys based on selected models
form.setValue("quick_think_api_key", getApiKeyForModel(quickThinkLlm, savedSettings));
form.setValue("deep_think_api_key", getApiKeyForModel(deepThinkLlm, savedSettings));
form.setValue("embedding_api_key", savedSettings.openai_api_key);
form.setValue("alpha_vantage_api_key", savedSettings.alpha_vantage_api_key);
}, [form, form.watch("quick_think_llm"), form.watch("deep_think_llm")]);
// 全選/取消全選
const toggleSelectAll = () => {
const currentAnalysts = form.getValues("analysts");
@ -505,299 +525,6 @@ export function AnalysisForm({ onSubmit, loading = false }: AnalysisFormProps) {
</div>
</div>
{/* API Configuration Section */}
<div className="space-y-4 border-t pt-6 mt-6">
<h3 className="text-lg font-semibold">API </h3>
<FormField
control={form.control}
name="quick_think_base_url"
render={({ field }) => (
<FormItem>
<FormLabel> Base URL</FormLabel>
<Select
onValueChange={(value) => {
if (value !== "custom") {
field.onChange(value);
} else {
field.onChange(""); // Clear value for custom input
}
}}
defaultValue={
[
"https://api.openai.com/v1",
"https://api.anthropic.com",
"https://api.x.ai/v1",
"https://api.deepseek.com/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
].includes(field.value || "")
? field.value
: "custom"
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="選擇 API 端點" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="https://api.openai.com/v1">
OpenAI ()
</SelectItem>
<SelectItem value="https://api.anthropic.com">
Anthropic
</SelectItem>
<SelectItem value="https://generativelanguage.googleapis.com/v1beta/openai">
Google (Gemini)
</SelectItem>
<SelectItem value="https://api.x.ai/v1">
Grok (xAI)
</SelectItem>
<SelectItem value="https://api.deepseek.com/v1">
DeepSeek
</SelectItem>
<SelectItem value="https://dashscope-intl.aliyuncs.com/compatible-mode/v1">
Qwen (Alibaba)
</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
{(![
"https://api.openai.com/v1",
"https://api.anthropic.com",
"https://generativelanguage.googleapis.com/v1beta/openai",
"https://api.x.ai/v1",
"https://api.deepseek.com/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
].includes(field.value || "") ||
field.value === "") && (
<div className="mt-2">
<FormControl>
<Input
placeholder="請輸入自訂 Base URL"
value={field.value || ""}
onChange={field.onChange}
/>
</FormControl>
</div>
)}
<FormDescription>
API
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quick_think_api_key"
render={({ field }) => (
<FormItem>
<FormLabel> API Key *</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription>
API Key
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="deep_think_base_url"
render={({ field }) => (
<FormItem>
<FormLabel> Base URL</FormLabel>
<Select
onValueChange={(value) => {
if (value !== "custom") {
field.onChange(value);
} else {
field.onChange(""); // Clear value for custom input
}
}}
defaultValue={
[
"https://api.openai.com/v1",
"https://api.anthropic.com",
"https://api.x.ai/v1",
"https://api.deepseek.com/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
].includes(field.value || "")
? field.value
: "custom"
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="選擇 API 端點" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="https://api.openai.com/v1">
OpenAI ()
</SelectItem>
<SelectItem value="https://api.anthropic.com">
Anthropic
</SelectItem>
<SelectItem value="https://generativelanguage.googleapis.com/v1beta/openai">
Google (Gemini)
</SelectItem>
<SelectItem value="https://api.x.ai/v1">
Grok (xAI)
</SelectItem>
<SelectItem value="https://api.deepseek.com/v1">
DeepSeek
</SelectItem>
<SelectItem value="https://dashscope-intl.aliyuncs.com/compatible-mode/v1">
Qwen (Alibaba)
</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
{(![
"https://api.openai.com/v1",
"https://api.anthropic.com",
"https://generativelanguage.googleapis.com/v1beta/openai",
"https://api.x.ai/v1",
"https://api.deepseek.com/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
].includes(field.value || "") ||
field.value === "") && (
<div className="mt-2">
<FormControl>
<Input
placeholder="請輸入自訂 Base URL"
value={field.value || ""}
onChange={field.onChange}
/>
</FormControl>
</div>
)}
<FormDescription>
API
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="deep_think_api_key"
render={({ field }) => (
<FormItem>
<FormLabel> API Key *</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription>
API Key
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="embedding_base_url"
render={({ field }) => (
<FormItem>
<FormLabel> Base URL</FormLabel>
<Select
onValueChange={(value) => {
if (value !== "custom") {
field.onChange(value);
} else {
field.onChange(""); // Clear value for custom input
}
}}
defaultValue={
field.value === "https://api.openai.com/v1" ||
!field.value
? "https://api.openai.com/v1"
: "custom"
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="選擇嵌入模型端點" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="https://api.openai.com/v1">
OpenAI ()
</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
{field.value !== "https://api.openai.com/v1" && (
<div className="mt-2">
<FormControl>
<Input
placeholder="請輸入自訂 Base URL"
value={field.value || ""}
onChange={field.onChange}
/>
</FormControl>
</div>
)}
<FormDescription>
API
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="embedding_api_key"
render={({ field }) => (
<FormItem>
<FormLabel> API Key *</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription> API Key</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="alpha_vantage_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Alpha Vantage API Key *</FormLabel>
<FormControl>
<Input
type="password"
placeholder="輸入 Alpha Vantage API Key必填"
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-500 to-pink-500 dark:from-blue-600 dark:to-purple-600 hover:from-blue-600 hover:to-pink-600 dark:hover:from-blue-700 dark:hover:to-purple-700 shadow-lg hover:shadow-xl transition-all animate-heartbeat"

View File

@ -3,6 +3,7 @@
*/
import Link from "next/link";
import { ThemeToggle } from "@/components/theme/ThemeToggle";
import { ApiSettingsDialog } from "@/components/settings/ApiSettingsDialog";
export function Header() {
return (
@ -28,6 +29,7 @@ export function Header() {
>
</Link>
<ApiSettingsDialog />
<ThemeToggle />
</nav>
</div>

View File

@ -0,0 +1,269 @@
/**
* API Settings Dialog Component
*/
"use client";
import { useState, useEffect } from "react";
import { Settings } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
getApiSettings,
saveApiSettings,
clearApiSettings,
type ApiSettings,
DEFAULT_API_SETTINGS,
} from "@/lib/storage";
const formSchema = z.object({
// Required
openai_api_key: z.string().min(1, "OpenAI API Key 為必填"),
alpha_vantage_api_key: z.string().min(1, "Alpha Vantage API Key 為必填"),
// Optional
anthropic_api_key: z.string().optional().or(z.literal("")),
google_api_key: z.string().optional().or(z.literal("")),
grok_api_key: z.string().optional().or(z.literal("")),
deepseek_api_key: z.string().optional().or(z.literal("")),
qwen_api_key: z.string().optional().or(z.literal("")),
});
type FormValues = z.infer<typeof formSchema>;
export function ApiSettingsDialog() {
const [open, setOpen] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: getApiSettings(),
});
// Load settings when dialog opens
useEffect(() => {
if (open) {
const settings = getApiSettings();
form.reset(settings);
setSaveSuccess(false);
}
}, [open, form]);
const onSubmit = (values: FormValues) => {
try {
// Type assertion since our form values match ApiSettings structure
saveApiSettings(values as ApiSettings);
setSaveSuccess(true);
setTimeout(() => {
setSaveSuccess(false);
setOpen(false);
}, 1500);
} catch (error) {
console.error("Failed to save settings:", error);
}
};
const handleClear = () => {
clearApiSettings();
form.reset(DEFAULT_API_SETTINGS);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
title="API 設定"
>
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>API </DialogTitle>
<DialogDescription>
API
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Required Section */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-primary"></h3>
{/* OpenAI API Key */}
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>OpenAI API Key *</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription>
OpenAI GPT-4, GPT-5, o4
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Alpha Vantage API Key */}
<FormField
control={form.control}
name="alpha_vantage_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Alpha Vantage API Key *</FormLabel>
<FormControl>
<Input
type="password"
placeholder="輸入 Alpha Vantage API Key"
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Optional Section */}
<div className="space-y-4 border-t pt-4">
<h3 className="text-lg font-semibold text-muted-foreground">
</h3>
{/* Anthropic API Key */}
<FormField
control={form.control}
name="anthropic_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Anthropic API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription> Claude </FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Google API Key */}
<FormField
control={form.control}
name="google_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Google API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="..." {...field} />
</FormControl>
<FormDescription> Gemini </FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Grok API Key */}
<FormField
control={form.control}
name="grok_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Grok (xAI) API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="xai-..." {...field} />
</FormControl>
<FormDescription> Grok </FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* DeepSeek API Key */}
<FormField
control={form.control}
name="deepseek_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>DeepSeek API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription> DeepSeek </FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Qwen API Key */}
<FormField
control={form.control}
name="qwen_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Qwen (Alibaba) API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription> Qwen </FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{saveSuccess && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 text-green-800 dark:text-green-300 text-sm">
</div>
)}
<div className="flex gap-2 pt-4">
<Button type="submit" className="flex-1">
</Button>
<Button
type="button"
variant="outline"
onClick={handleClear}
className="flex-1"
>
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,92 @@
/**
* Helper functions for API configuration
*/
import { ApiSettings } from "./storage";
/**
* Get the base URL for a given LLM model
*/
export function getBaseUrlForModel(model: string): string {
// OpenAI models
if (
model.startsWith("gpt-") ||
model.startsWith("o4-") ||
model.startsWith("o1-")
) {
return "https://api.openai.com/v1";
}
// Anthropic models
if (model.startsWith("claude-")) {
return "https://api.anthropic.com";
}
// Google models
if (model.startsWith("gemini-")) {
return "https://generativelanguage.googleapis.com/v1beta/openai";
}
// Grok models
if (model.startsWith("grok-")) {
return "https://api.x.ai/v1";
}
// DeepSeek models
if (model.startsWith("deepseek-")) {
return "https://api.deepseek.com/v1";
}
// Qwen models
if (model.startsWith("qwen")) {
return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
}
// Default to OpenAI
return "https://api.openai.com/v1";
}
/**
* Get the API key for a given LLM model from saved settings
*/
export function getApiKeyForModel(
model: string,
settings: ApiSettings
): string {
// OpenAI models
if (
model.startsWith("gpt-") ||
model.startsWith("o4-") ||
model.startsWith("o1-")
) {
return settings.openai_api_key;
}
// Anthropic models
if (model.startsWith("claude-")) {
return settings.anthropic_api_key || "";
}
// Google models
if (model.startsWith("gemini-")) {
return settings.google_api_key || "";
}
// Grok models
if (model.startsWith("grok-")) {
return settings.grok_api_key || "";
}
// DeepSeek models
if (model.startsWith("deepseek-")) {
return settings.deepseek_api_key || "";
}
// Qwen models
if (model.startsWith("qwen")) {
return settings.qwen_api_key || "";
}
// Default to OpenAI
return settings.openai_api_key;
}

81
frontend/lib/storage.ts Normal file
View File

@ -0,0 +1,81 @@
/**
* localStorage utility for API settings
*/
export interface ApiSettings {
// Required providers
openai_api_key: string;
alpha_vantage_api_key: string;
// Optional providers
anthropic_api_key: string;
google_api_key: string;
grok_api_key: string;
deepseek_api_key: string;
qwen_api_key: string;
}
const STORAGE_KEY = "tradingagents_api_settings";
export const DEFAULT_API_SETTINGS: ApiSettings = {
openai_api_key: "",
alpha_vantage_api_key: "",
anthropic_api_key: "",
google_api_key: "",
grok_api_key: "",
deepseek_api_key: "",
qwen_api_key: "",
};
/**
* Get API settings from localStorage
*/
export function getApiSettings(): ApiSettings {
if (typeof window === "undefined") {
return DEFAULT_API_SETTINGS;
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Merge with defaults to handle any missing fields
return { ...DEFAULT_API_SETTINGS, ...parsed };
}
} catch (error) {
console.error("Error reading API settings from localStorage:", error);
}
return DEFAULT_API_SETTINGS;
}
/**
* Save API settings to localStorage
*/
export function saveApiSettings(settings: ApiSettings): void {
if (typeof window === "undefined") {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
console.error("Error saving API settings to localStorage:", error);
throw error;
}
}
/**
* Clear API settings from localStorage
*/
export function clearApiSettings(): void {
if (typeof window === "undefined") {
return;
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error("Error clearing API settings from localStorage:", error);
}
}