refactor(frontend): split large config page into sub-components

This commit is contained in:
Lv, Qi 2025-11-19 06:51:50 +08:00
parent 4fef6bf35b
commit 75aa5e4add
9 changed files with 1564 additions and 941 deletions

View File

@ -0,0 +1,640 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import { useLlmProviders, updateLlmProviders, discoverProviderModels } from '@/hooks/useApi';
import type { LlmProvidersConfig, LlmModel } from '@/types';
export function AIConfigTab({
newProviderBaseUrl,
setNewProviderBaseUrl
}: {
newProviderBaseUrl: string;
setNewProviderBaseUrl: (url: string) => void;
}) {
const { data: llmProviders, mutate: mutateLlmProviders } = useLlmProviders();
const [localLlmProviders, setLocalLlmProviders] = useState<LlmProvidersConfig>({});
const [isSavingLlm, setIsSavingLlm] = useState(false);
const [llmSaveMessage, setLlmSaveMessage] = useState('');
// New Provider Form State
const [newProviderId, setNewProviderId] = useState('');
// newProviderBaseUrl is passed as prop
const [newProviderApiKey, setNewProviderApiKey] = useState('');
// Provider Management State
const [pendingApiKeys, setPendingApiKeys] = useState<Record<string, string>>({});
const [editingApiKey, setEditingApiKey] = useState<Record<string, boolean>>({});
const [discoverMessages, setDiscoverMessages] = useState<Record<string, string>>({});
const [modelPickerOpen, setModelPickerOpen] = useState<Record<string, boolean>>({});
const [candidateModels, setCandidateModels] = useState<Record<string, string[]>>({});
const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
const [selectedCandidates, setSelectedCandidates] = useState<Record<string, Record<string, boolean>>>({});
// New Model Input State
const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
const [newModelNameInputs, setNewModelNameInputs] = useState<Record<string, string>>({});
const [newModelMenuOpen, setNewModelMenuOpen] = useState<Record<string, boolean>>({});
const [newModelHighlightIndex, setNewModelHighlightIndex] = useState<Record<string, number>>({});
// Refs for Auto-save
const hasInitializedLlmRef = useRef(false);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savingStartedAtRef = useRef<number>(0);
const llmDirtyRef = useRef(false);
const latestServerPayloadRef = useRef<string>('');
const lastSavedPayloadRef = useRef<string>('');
const markLlmDirty = useCallback(() => {
llmDirtyRef.current = true;
}, []);
const normalizeProviders = useCallback((obj: LlmProvidersConfig) => {
const cloned: LlmProvidersConfig = JSON.parse(JSON.stringify(obj || {}));
Object.keys(cloned).forEach(pid => {
if (!cloned[pid].name || cloned[pid].name.trim().length === 0) {
cloned[pid].name = pid;
}
});
return cloned;
}, []);
const buildMergedLlmPayload = useCallback(() => {
const merged: LlmProvidersConfig = normalizeProviders(localLlmProviders || {});
// 待更新的 API Key 覆盖
Object.entries(pendingApiKeys || {}).forEach(([pid, key]) => {
if (merged[pid]) merged[pid].api_key = key;
});
return merged;
}, [localLlmProviders, pendingApiKeys, normalizeProviders]);
const flushSaveLlmImmediate = useCallback(async () => {
const payload = buildMergedLlmPayload();
const payloadStr = JSON.stringify(payload);
if (payloadStr === latestServerPayloadRef.current || payloadStr === lastSavedPayloadRef.current) {
return;
}
savingStartedAtRef.current = Date.now();
setIsSavingLlm(true);
setLlmSaveMessage('自动保存中...');
try {
const updated = await updateLlmProviders(payload);
await mutateLlmProviders(updated, false);
lastSavedPayloadRef.current = payloadStr;
llmDirtyRef.current = false;
setPendingApiKeys({});
setEditingApiKey({});
setLlmSaveMessage('已自动保存');
} catch (e: any) {
setLlmSaveMessage(`保存失败: ${e?.message || '未知错误'}`);
} finally {
const elapsed = Date.now() - (savingStartedAtRef.current || 0);
const minMs = 1000;
const waitMs = elapsed >= minMs ? 0 : (minMs - elapsed);
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
setIsSavingLlm(false);
setTimeout(() => setLlmSaveMessage(''), 3000);
}
}, [buildMergedLlmPayload, mutateLlmProviders, setPendingApiKeys, setEditingApiKey]);
// Initialize from Server Data
useEffect(() => {
if (llmProviders) {
setLocalLlmProviders(llmProviders);
const normalized = normalizeProviders(llmProviders);
latestServerPayloadRef.current = JSON.stringify(normalized);
llmDirtyRef.current = false;
}
}, [llmProviders, normalizeProviders]);
// Auto-save Effect
useEffect(() => {
if (!hasInitializedLlmRef.current) {
hasInitializedLlmRef.current = true;
return;
}
if (!llmDirtyRef.current) {
return;
}
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
void flushSaveLlmImmediate();
}, 500);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, [localLlmProviders, pendingApiKeys, flushSaveLlmImmediate]);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>AI Provider </CardTitle>
<div className="flex items-center gap-2 h-5">
{isSavingLlm && (
<>
<Spinner className="text-gray-600" />
<span className="text-xs text-muted-foreground">...</span>
</>
)}
{!isSavingLlm && llmSaveMessage && (
<span className={`text-xs ${llmSaveMessage.includes('失败') ? 'text-red-600' : 'text-green-600'}`}>
{llmSaveMessage}
</span>
)}
</div>
</div>
<CardDescription> ProviderAPI KeyBase URL</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 新增 Provider */}
<div className="space-y-3 p-4 border rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="space-y-2">
<Label htmlFor="provider-id">Provider ID</Label>
<Input id="provider-id" value={newProviderId} onChange={(e) => setNewProviderId(e.target.value)} placeholder="例如: openai_official" />
</div>
<div className="space-y-2">
<Label htmlFor="provider-baseurl">Base URL</Label>
<Input id="provider-baseurl" value={newProviderBaseUrl} onChange={(e) => setNewProviderBaseUrl(e.target.value)} placeholder="例如: https://api.openai.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="provider-apikey">API Key</Label>
<Input id="provider-apikey" type="password" value={newProviderApiKey} onChange={(e) => setNewProviderApiKey(e.target.value)} placeholder="输入新Provider的API Key" />
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
if (!newProviderId || !newProviderBaseUrl || !newProviderApiKey) {
setLlmSaveMessage('请完整填写 Provider 信息');
setTimeout(() => setLlmSaveMessage(''), 5000);
return;
}
if (localLlmProviders[newProviderId]) {
setLlmSaveMessage('Provider ID 已存在');
setTimeout(() => setLlmSaveMessage(''), 5000);
return;
}
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[newProviderId]: {
name: newProviderId,
api_base_url: newProviderBaseUrl,
api_key: newProviderApiKey,
models: [],
},
}));
setNewProviderId('');
setNewProviderBaseUrl('');
setNewProviderApiKey('');
}}
>
Provider
</Button>
</div>
</div>
<Separator />
{/* Provider 列表 */}
<div className="space-y-6">
{Object.entries(localLlmProviders || {}).map(([providerId, provider]) => {
const message = discoverMessages[providerId];
const candidates = candidateModels[providerId] || [];
const query = (modelSearch[providerId] || '').trim().toLowerCase();
const filteredCandidates = candidates.filter(id => id.toLowerCase().includes(query));
const selectedMap = selectedCandidates[providerId] || {};
return (
<div key={providerId} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{providerId}</h3>
<Badge variant="secondary">Base on ID</Badge>
<span className="ml-2 inline-flex items-center justify-start w-24 h-5">
{isSavingLlm ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Spinner className="text-gray-600" />
</span>
) : null}
</span>
</div>
<div className="text-sm text-muted-foreground">{provider.api_base_url}</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={async () => {
try {
// 刷新前强制保存,确保后端使用最新配置
if (llmDirtyRef.current) {
await flushSaveLlmImmediate();
}
const resp = await discoverProviderModels(providerId);
const data = Array.isArray(resp?.data) ? resp.data : null;
if (!data) {
setDiscoverMessages(prev => ({ ...prev, [providerId]: '候选加载失败:不支持的响应结构' }));
setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
return;
}
const discovered: string[] = data
.map((x: any) => (typeof x?.id === 'string' ? x.id : null))
.filter((x: any) => typeof x === 'string');
setCandidateModels(prev => ({ ...prev, [providerId]: discovered }));
setDiscoverMessages(prev => ({ ...prev, [providerId]: `已加载候选模型 ${discovered.length}` }));
setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
} catch (e: any) {
setDiscoverMessages(prev => ({ ...prev, [providerId]: `加载失败:${e?.message || '未知错误'}` }));
setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
}
}}></Button>
<Button variant="outline" onClick={() => {
setModelPickerOpen(prev => ({ ...prev, [providerId]: !prev[providerId] }));
}}>{modelPickerOpen[providerId] ? '收起模型管理' : '管理模型'}</Button>
<Button
variant="destructive"
onClick={() => {
const next = { ...localLlmProviders };
delete next[providerId];
markLlmDirty();
setLocalLlmProviders(next);
}}
>
Provider
</Button>
</div>
</div>
{message && (
<div className="text-sm">{message}</div>
)}
{modelPickerOpen[providerId] && (
<div className="space-y-3 p-3 border rounded-md bg-white/40">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
<div className="space-y-2">
<Label></Label>
<Input
value={modelSearch[providerId] || ''}
onChange={(e) => setModelSearch(prev => ({ ...prev, [providerId]: e.target.value }))}
placeholder="输入关键字过滤(前缀/包含均可)"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => {
const sel: Record<string, boolean> = { ...(selectedCandidates[providerId] || {}) };
filteredCandidates.forEach(id => { sel[id] = true; });
setSelectedCandidates(prev => ({ ...prev, [providerId]: sel }));
}}></Button>
<Button variant="outline" onClick={() => {
setSelectedCandidates(prev => ({ ...prev, [providerId]: {} }));
}}></Button>
</div>
<div className="flex gap-2">
<Button onClick={() => {
const sel = selectedCandidates[providerId] || {};
const toAddIds = Object.keys(sel).filter(id => sel[id]);
if (toAddIds.length === 0) return;
markLlmDirty();
setLocalLlmProviders(prev => {
const existing = new Set((prev[providerId]?.models || []).map(m => m.model_id));
const toAdd: LlmModel[] = toAddIds
.filter(id => !existing.has(id))
.map(id => ({ model_id: id, name: null, is_active: true }));
return {
...prev,
[providerId]: {
...prev[providerId],
models: [...(prev[providerId]?.models || []), ...toAdd],
},
};
});
setSelectedCandidates(prev => ({ ...prev, [providerId]: {} }));
}}></Button>
</div>
</div>
<ScrollArea className="border rounded-md" style={{ maxHeight: 280 }}>
<div className="p-2 space-y-1">
{filteredCandidates.length === 0 ? (
<div className="text-sm text-muted-foreground p-2"></div>
) : (
filteredCandidates.map(id => {
const checked = !!selectedMap[id];
return (
<div key={id} className="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
<div className="truncate text-sm">{id}</div>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setSelectedCandidates(prev => {
const cur = { ...(prev[providerId] || {}) };
if (v) cur[id] = true; else delete cur[id];
return { ...prev, [providerId]: cur };
});
}}
/>
</div>
);
})
)}
</div>
</ScrollArea>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Base URL</Label>
<Input
value={provider.api_base_url}
onChange={(e) => {
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: { ...prev[providerId], api_base_url: e.target.value },
}));
}}
/>
</div>
<div className="space-y-2">
<Label>API Key</Label>
{!editingApiKey[providerId] ? (
<div className="flex items-center gap-2">
<Badge variant={provider.api_key ? 'default' : 'secondary'}>
{provider.api_key ? '已配置' : '未配置'}
</Badge>
<Button
variant="outline"
onClick={() => setEditingApiKey(prev => ({ ...prev, [providerId]: true }))}
>
API Key
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="输入新的 API Key"
value={pendingApiKeys[providerId] || ''}
onChange={(e) => {
markLlmDirty();
setPendingApiKeys(prev => ({ ...prev, [providerId]: e.target.value }));
}}
/>
<Button
variant="outline"
onClick={() => setEditingApiKey(prev => ({ ...prev, [providerId]: false }))}
>
</Button>
</div>
)}
</div>
</div>
<div className="space-y-2 pt-2">
<Label></Label>
<div className="space-y-3">
{(provider.models || []).map((m) => (
<div key={m.model_id} className="grid grid-cols-1 md:grid-cols-3 gap-3 items-center">
<Input
value={m.model_id}
onChange={(e) => {
const v = e.target.value;
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).map(mm =>
mm.model_id === m.model_id ? { ...mm, model_id: v } : mm
),
},
}));
}}
/>
<Input
value={m.name || ''}
onChange={(e) => {
const v = e.target.value;
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).map(mm =>
mm.model_id === m.model_id ? { ...mm, name: v } : mm
),
},
}));
}}
placeholder="可选别名"
/>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Checkbox
id={`${providerId}-${m.model_id}-active`}
checked={!!m.is_active}
onCheckedChange={(checked) => {
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).map(mm =>
mm.model_id === m.model_id ? { ...mm, is_active: !!checked } : mm
),
},
}));
}}
/>
<label htmlFor={`${providerId}-${m.model_id}-active`} className="text-sm"></label>
</div>
<Button
variant="destructive"
onClick={() => {
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).filter(mm => mm.model_id !== m.model_id),
},
}));
}}
>
</Button>
</div>
</div>
))}
</div>
<div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-start">
<div className="space-y-1 relative">
<Input
placeholder="新增模型 ID可从候选中选择"
value={newModelInputs[providerId] || ''}
onChange={(e) => {
const v = e.target.value;
setNewModelInputs(prev => ({ ...prev, [providerId]: v }));
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true }));
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 }));
}}
onFocus={() => {
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true }));
}}
onKeyDown={(e) => {
const typedRaw = (newModelInputs[providerId] || '');
const typed = typedRaw.trim().toLowerCase();
const existing = new Set((provider.models || []).map(m => m.model_id));
const list = (candidateModels[providerId] || [])
.filter(id => id.toLowerCase().includes(typed))
.filter(id => !existing.has(id))
.slice(0, 50);
const hi = newModelHighlightIndex[providerId] ?? 0;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (list.length > 0) {
const next = Math.min(hi + 1, list.length - 1);
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next }));
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (list.length > 0) {
const next = Math.max(hi - 1, 0);
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next }));
}
return;
}
if (e.key === 'Escape') {
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false }));
return;
}
if (e.key === 'Enter') {
const chosen = list.length > 0 && (newModelMenuOpen[providerId] ?? false) ? list[hi] : (newModelInputs[providerId] || '').trim();
const id = (chosen || '').trim();
if (!id) return;
const existsSet = new Set((provider.models || []).map(m => m.model_id));
if (existsSet.has(id)) return;
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: [
...(prev[providerId].models || []),
{ model_id: id, name: (newModelNameInputs[providerId] || '') || null, is_active: true },
],
},
}));
setNewModelInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelNameInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false }));
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 }));
}
}}
/>
{(() => {
const typed = (newModelInputs[providerId] || '').trim().toLowerCase();
const existing = new Set((provider.models || []).map(m => m.model_id));
const list = (candidateModels[providerId] || [])
.filter(id => id.toLowerCase().includes(typed))
.filter(id => !existing.has(id))
.slice(0, 50);
const open = !!newModelMenuOpen[providerId];
if (!typed || list.length === 0 || !open) return null;
const hi = newModelHighlightIndex[providerId] ?? 0;
return (
<div className="absolute z-10 mt-1 w-full border rounded-md bg-white shadow max-h-60 overflow-auto">
{list.map((id, idx) => (
<div
key={id}
className={`px-2 py-1 text-sm cursor-pointer ${hi === idx ? 'bg-gray-100' : 'hover:bg-gray-50'}`}
onMouseEnter={() => {
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: idx }));
}}
onMouseDown={(ev) => {
// 防止失焦导致菜单关闭
ev.preventDefault();
}}
onClick={() => {
setNewModelInputs(prev => ({ ...prev, [providerId]: id }));
}}
>
{id}
</div>
))}
</div>
);
})()}
</div>
<Input
placeholder="模型别名(可选)"
value={newModelNameInputs[providerId] || ''}
onChange={(e) => {
const v = e.target.value;
setNewModelNameInputs(prev => ({ ...prev, [providerId]: v }));
}}
/>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
const id = (newModelInputs[providerId] || '').trim();
if (!id) return;
const exists = new Set((provider.models || []).map(m => m.model_id));
if (exists.has(id)) return;
const name = (newModelNameInputs[providerId] || '').trim();
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: [
...(prev[providerId].models || []),
{ model_id: id, name: name || null, is_active: true },
],
},
}));
setNewModelInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelNameInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false }));
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 }));
}}
>
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@ -1,487 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { useAiConfig } from '../hooks/useAiConfig';
import { LlmModel } from '@/types';
export function AiConfigTab() {
const {
localLlmProviders, setLocalLlmProviders,
isSavingLlm, llmSaveMessage, setLlmSaveMessage,
newProviderId, setNewProviderId,
newProviderBaseUrl, setNewProviderBaseUrl,
newProviderApiKey, setNewProviderApiKey,
pendingApiKeys, setPendingApiKeys,
editingApiKey, setEditingApiKey,
discoverMessages,
modelPickerOpen, setModelPickerOpen,
candidateModels, setCandidateModels,
modelSearch, setModelSearch,
selectedCandidates, setSelectedCandidates,
newModelInputs, setNewModelInputs,
newModelNameInputs, setNewModelNameInputs,
newModelMenuOpen, setNewModelMenuOpen,
newModelHighlightIndex, setNewModelHighlightIndex,
markLlmDirty,
handleAddProvider,
handleDiscoverModels,
flushSaveLlmImmediate,
llmDirtyRef
} = useAiConfig();
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>AI Provider </CardTitle>
<div className="flex items-center gap-2 h-5">
{isSavingLlm && (
<>
<Spinner className="text-gray-600" />
<span className="text-xs text-muted-foreground">...</span>
</>
)}
{!isSavingLlm && llmSaveMessage && (
<span className={`text-xs ${llmSaveMessage.includes('失败') ? 'text-red-600' : 'text-green-600'}`}>
{llmSaveMessage}
</span>
)}
</div>
</div>
<CardDescription> ProviderAPI KeyBase URL</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 新增 Provider */}
<div className="space-y-3 p-4 border rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="space-y-2">
<Label htmlFor="provider-id">Provider ID</Label>
<Input id="provider-id" value={newProviderId} onChange={(e) => setNewProviderId(e.target.value)} placeholder="例如: openai_official" />
</div>
<div className="space-y-2">
<Label htmlFor="provider-baseurl">Base URL</Label>
<Input id="provider-baseurl" value={newProviderBaseUrl} onChange={(e) => setNewProviderBaseUrl(e.target.value)} placeholder="例如: https://api.openai.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="provider-apikey">API Key</Label>
<Input id="provider-apikey" type="password" value={newProviderApiKey} onChange={(e) => setNewProviderApiKey(e.target.value)} placeholder="输入新Provider的API Key" />
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleAddProvider}>
Provider
</Button>
</div>
</div>
<Separator />
{/* Provider 列表 */}
<div className="space-y-6">
{Object.entries(localLlmProviders || {}).map(([providerId, provider]) => {
const message = discoverMessages[providerId];
const candidates = candidateModels[providerId] || [];
const query = (modelSearch[providerId] || '').trim().toLowerCase();
const filteredCandidates = candidates.filter(id => id.toLowerCase().includes(query));
const selectedMap = selectedCandidates[providerId] || {};
return (
<div key={providerId} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{providerId}</h3>
<Badge variant="secondary">Base on ID</Badge>
<span className="ml-2 inline-flex items-center justify-start w-24 h-5">
{isSavingLlm ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Spinner className="text-gray-600" />
</span>
) : null}
</span>
</div>
<div className="text-sm text-muted-foreground">{provider.api_base_url}</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => handleDiscoverModels(providerId)}></Button>
<Button variant="outline" onClick={() => {
setModelPickerOpen(prev => ({ ...prev, [providerId]: !prev[providerId] }));
}}>{modelPickerOpen[providerId] ? '收起模型管理' : '管理模型'}</Button>
<Button
variant="destructive"
onClick={() => {
const next = { ...localLlmProviders };
delete next[providerId];
markLlmDirty();
setLocalLlmProviders(next);
}}
>
Provider
</Button>
</div>
</div>
{message && (
<div className="text-sm">{message}</div>
)}
{modelPickerOpen[providerId] && (
<div className="space-y-3 p-3 border rounded-md bg-white/40">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
<div className="space-y-2">
<Label></Label>
<Input
value={modelSearch[providerId] || ''}
onChange={(e) => setModelSearch(prev => ({ ...prev, [providerId]: e.target.value }))}
placeholder="输入关键字过滤(前缀/包含均可)"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => {
const sel: Record<string, boolean> = { ...(selectedCandidates[providerId] || {}) };
filteredCandidates.forEach(id => { sel[id] = true; });
setSelectedCandidates(prev => ({ ...prev, [providerId]: sel }));
}}></Button>
<Button variant="outline" onClick={() => {
setSelectedCandidates(prev => ({ ...prev, [providerId]: {} }));
}}></Button>
</div>
<div className="flex gap-2">
<Button onClick={() => {
const sel = selectedCandidates[providerId] || {};
const toAddIds = Object.keys(sel).filter(id => sel[id]);
if (toAddIds.length === 0) return;
markLlmDirty();
setLocalLlmProviders(prev => {
const existing = new Set((prev[providerId]?.models || []).map(m => m.model_id));
const toAdd: LlmModel[] = toAddIds
.filter(id => !existing.has(id))
.map(id => ({ model_id: id, name: null, is_active: true }));
return {
...prev,
[providerId]: {
...prev[providerId],
models: [...(prev[providerId]?.models || []), ...toAdd],
},
};
});
setSelectedCandidates(prev => ({ ...prev, [providerId]: {} }));
}}></Button>
</div>
</div>
<ScrollArea className="border rounded-md" style={{ maxHeight: 280 }}>
<div className="p-2 space-y-1">
{filteredCandidates.length === 0 ? (
<div className="text-sm text-muted-foreground p-2"></div>
) : (
filteredCandidates.map(id => {
const checked = !!selectedMap[id];
return (
<div key={id} className="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
<div className="truncate text-sm">{id}</div>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setSelectedCandidates(prev => {
const cur = { ...(prev[providerId] || {}) };
if (v) cur[id] = true; else delete cur[id];
return { ...prev, [providerId]: cur };
});
}}
/>
</div>
);
})
)}
</div>
</ScrollArea>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Base URL</Label>
<Input
value={provider.api_base_url}
onChange={(e) => {
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: { ...prev[providerId], api_base_url: e.target.value },
}));
}}
/>
</div>
<div className="space-y-2">
<Label>API Key</Label>
{!editingApiKey[providerId] ? (
<div className="flex items-center gap-2">
<Badge variant={provider.api_key ? 'default' : 'secondary'}>
{provider.api_key ? '已配置' : '未配置'}
</Badge>
<Button
variant="outline"
onClick={() => setEditingApiKey(prev => ({ ...prev, [providerId]: true }))}
>
API Key
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="输入新的 API Key"
value={pendingApiKeys[providerId] || ''}
onChange={(e) => {
markLlmDirty();
setPendingApiKeys(prev => ({ ...prev, [providerId]: e.target.value }));
}}
/>
<Button
variant="outline"
onClick={() => setEditingApiKey(prev => ({ ...prev, [providerId]: false }))}
>
</Button>
</div>
)}
</div>
</div>
<div className="space-y-2 pt-2">
<Label></Label>
<div className="space-y-3">
{(provider.models || []).map((m) => (
<div key={m.model_id} className="grid grid-cols-1 md:grid-cols-3 gap-3 items-center">
<Input
value={m.model_id}
onChange={(e) => {
const v = e.target.value;
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).map(mm =>
mm.model_id === m.model_id ? { ...mm, model_id: v } : mm
),
},
}));
}}
/>
<Input
value={m.name || ''}
onChange={(e) => {
const v = e.target.value;
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).map(mm =>
mm.model_id === m.model_id ? { ...mm, name: v } : mm
),
},
}));
}}
placeholder="可选别名"
/>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Checkbox
id={`${providerId}-${m.model_id}-active`}
checked={!!m.is_active}
onCheckedChange={(checked) => {
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).map(mm =>
mm.model_id === m.model_id ? { ...mm, is_active: !!checked } : mm
),
},
}));
}}
/>
<label htmlFor={`${providerId}-${m.model_id}-active`} className="text-sm"></label>
</div>
<Button
variant="destructive"
onClick={() => {
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: (prev[providerId].models || []).filter(mm => mm.model_id !== m.model_id),
},
}));
}}
>
</Button>
</div>
</div>
))}
</div>
<div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-start">
<div className="space-y-1 relative">
<Input
placeholder="新增模型 ID可从候选中选择"
value={newModelInputs[providerId] || ''}
onChange={(e) => {
const v = e.target.value;
setNewModelInputs(prev => ({ ...prev, [providerId]: v }));
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true }));
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 }));
}}
onFocus={() => {
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true }));
}}
onKeyDown={(e) => {
const typedRaw = (newModelInputs[providerId] || '');
const typed = typedRaw.trim().toLowerCase();
const existing = new Set((provider.models || []).map(m => m.model_id));
const list = (candidateModels[providerId] || [])
.filter(id => id.toLowerCase().includes(typed))
.filter(id => !existing.has(id))
.slice(0, 50);
const hi = newModelHighlightIndex[providerId] ?? 0;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (list.length > 0) {
const next = Math.min(hi + 1, list.length - 1);
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next }));
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (list.length > 0) {
const next = Math.max(hi - 1, 0);
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next }));
}
return;
}
if (e.key === 'Escape') {
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false }));
return;
}
if (e.key === 'Enter') {
const chosen = list.length > 0 && (newModelMenuOpen[providerId] ?? false) ? list[hi] : (newModelInputs[providerId] || '').trim();
const id = (chosen || '').trim();
if (!id) return;
const existsSet = new Set((provider.models || []).map(m => m.model_id));
if (existsSet.has(id)) return;
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: [
...(prev[providerId].models || []),
{ model_id: id, name: (newModelNameInputs[providerId] || '') || null, is_active: true },
],
},
}));
setNewModelInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelNameInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false }));
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 }));
}
}}
/>
{(() => {
const typed = (newModelInputs[providerId] || '').trim().toLowerCase();
const existing = new Set((provider.models || []).map(m => m.model_id));
const list = (candidateModels[providerId] || [])
.filter(id => id.toLowerCase().includes(typed))
.filter(id => !existing.has(id))
.slice(0, 50);
const open = !!newModelMenuOpen[providerId];
if (!typed || list.length === 0 || !open) return null;
const hi = newModelHighlightIndex[providerId] ?? 0;
return (
<div className="absolute z-10 mt-1 w-full border rounded-md bg-white shadow max-h-60 overflow-auto">
{list.map((id, idx) => (
<div
key={id}
className={`px-2 py-1 text-sm cursor-pointer ${hi === idx ? 'bg-gray-100' : 'hover:bg-gray-50'}`}
onMouseEnter={() => {
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: idx }));
}}
onMouseDown={(ev) => {
// 防止失焦导致菜单关闭
ev.preventDefault();
}}
onClick={() => {
setNewModelInputs(prev => ({ ...prev, [providerId]: id }));
}}
>
{id}
</div>
))}
</div>
);
})()}
</div>
<Input
placeholder="模型别名(可选)"
value={newModelNameInputs[providerId] || ''}
onChange={(e) => {
const v = e.target.value;
setNewModelNameInputs(prev => ({ ...prev, [providerId]: v }));
}}
/>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
const id = (newModelInputs[providerId] || '').trim();
if (!id) return;
const exists = new Set((provider.models || []).map(m => m.model_id));
if (exists.has(id)) return;
const name = (newModelNameInputs[providerId] || '').trim();
markLlmDirty();
setLocalLlmProviders(prev => ({
...prev,
[providerId]: {
...prev[providerId],
models: [
...(prev[providerId].models || []),
{ model_id: id, name: name || null, is_active: true },
],
},
}));
setNewModelInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelNameInputs(prev => ({ ...prev, [providerId]: '' }));
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false }));
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 }));
}}
>
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@ -1,213 +1,383 @@
import { useState, useEffect, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useAnalysisConfig } from '../hooks/useAnalysisConfig'; import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ModelSelector } from './ModelSelector'; import { ModelSelector } from './ModelSelector';
import { useAnalysisTemplateSets, updateAnalysisTemplateSets, useLlmProviders } from '@/hooks/useApi';
import type { AnalysisTemplateSets, LlmModel } from '@/types';
export function AnalysisConfigTab() { export function AnalysisConfigTab() {
const { // LLM Providers for Model Selector
localTemplateSets, const { data: llmProviders } = useLlmProviders();
selectedTemplateId, const allModels = useMemo(() => {
setSelectedTemplateId, if (!llmProviders) return [];
allModels, const models: { providerId: string; providerName: string; model: LlmModel }[] = [];
isSavingAnalysis, Object.entries(llmProviders).forEach(([pId, provider]) => {
analysisSaveMessage, provider.models.forEach(m => {
setAnalysisSaveMessage, if (m.is_active) {
models.push({
providerId: pId,
providerName: provider.name || pId,
model: m
});
}
});
});
return models;
}, [llmProviders]);
newTemplateId, setNewTemplateId, // Analysis Config State
newTemplateName, setNewTemplateName, const { data: initialAnalysisTemplateSets, mutate: mutateAnalysisTemplateSets } = useAnalysisTemplateSets();
isCreatingTemplate, setIsCreatingTemplate, const [localTemplateSets, setLocalTemplateSets] = useState<AnalysisTemplateSets>({});
handleAddTemplate, const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
handleDeleteTemplate,
isCreatingModule, setIsCreatingModule, const [isSavingAnalysis, setIsSavingAnalysis] = useState(false);
newModuleId, setNewModuleId, const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
newModuleName, setNewModuleName,
handleAddNewModule,
handleDeleteModule,
handleAnalysisChange, // State for creating/editing templates and modules
handleSaveAnalysis, const [newTemplateId, setNewTemplateId] = useState('');
updateAnalysisDependencies const [newTemplateName, setNewTemplateName] = useState('');
} = useAnalysisConfig(); const [isCreatingTemplate, setIsCreatingTemplate] = useState(false);
return ( const [isCreatingModule, setIsCreatingModule] = useState(false);
<div className="space-y-4"> const [newModuleId, setNewModuleId] = useState('');
<Card> const [newModuleName, setNewModuleName] = useState('');
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* --- Level 1: Template Set Management --- */} // Initialize local state
<div className="p-4 border rounded-lg bg-slate-50 space-y-4"> useEffect(() => {
<div className="flex items-center gap-4"> if (!initialAnalysisTemplateSets) return;
<Label className="font-semibold">:</Label> setLocalTemplateSets(initialAnalysisTemplateSets);
<Select // Only set if null (first load), to avoid resetting user selection on revalidation
value={selectedTemplateId || ''} setSelectedTemplateId(prev => prev ?? (Object.keys(initialAnalysisTemplateSets)[0] || null));
onValueChange={(id) => setSelectedTemplateId(id)} }, [initialAnalysisTemplateSets]);
>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="选择一个模板..." />
</SelectTrigger>
<SelectContent>
{Object.entries(localTemplateSets).map(([id, set]) => (
<SelectItem key={id} value={id}>{set.name} ({id})</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={() => setIsCreatingTemplate(true)} disabled={isCreatingTemplate}>+ </Button>
<Button variant="destructive" onClick={handleDeleteTemplate} disabled={!selectedTemplateId}></Button>
</div>
{isCreatingTemplate && ( const handleAnalysisChange = (moduleId: string, field: string, value: any) => {
<div className="space-y-3 p-3 border rounded-md border-dashed"> if (!selectedTemplateId) return;
<h4 className="font-semibold"></h4> setLocalTemplateSets(prev => ({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> ...prev,
<Input [selectedTemplateId]: {
placeholder="模板 ID (e.g., standard_v2)" ...prev[selectedTemplateId],
value={newTemplateId} modules: {
onChange={(e) => setNewTemplateId(e.target.value.replace(/\s/g, ''))} ...prev[selectedTemplateId].modules,
/> [moduleId]: {
<Input ...prev[selectedTemplateId].modules[moduleId],
placeholder="模板名称 (e.g., 标准分析模板V2)" [field]: value,
value={newTemplateName} },
onChange={(e) => setNewTemplateName(e.target.value)} },
/> },
</div> }));
<div className="flex gap-2"> };
<Button onClick={handleAddTemplate}></Button>
<Button variant="ghost" onClick={() => setIsCreatingTemplate(false)}></Button> const handleSaveAnalysis = async () => {
</div> setIsSavingAnalysis(true);
</div> setAnalysisSaveMessage('保存中...');
)} try {
const updated = await updateAnalysisTemplateSets(localTemplateSets);
await mutateAnalysisTemplateSets(updated, false);
setAnalysisSaveMessage('分析配置保存成功!');
} catch (e: any) {
setAnalysisSaveMessage(`保存失败: ${e.message}`);
} finally {
setIsSavingAnalysis(false);
setTimeout(() => setAnalysisSaveMessage(''), 5000);
}
};
const updateAnalysisDependencies = (moduleId: string, dependency: string, checked: boolean) => {
if (!selectedTemplateId) return;
setLocalTemplateSets(prev => {
const currentModule = prev[selectedTemplateId].modules[moduleId];
const currentDeps = currentModule.dependencies || [];
const newDeps = checked
? [...currentDeps, dependency]
: currentDeps.filter(d => d !== dependency);
return {
...prev,
[selectedTemplateId]: {
...prev[selectedTemplateId],
modules: {
...prev[selectedTemplateId].modules,
[moduleId]: {
...currentModule,
dependencies: [...new Set(newDeps)],
},
},
},
};
});
};
const handleAddTemplate = () => {
if (!newTemplateId || !newTemplateName) {
setAnalysisSaveMessage('模板 ID 和名称不能为空');
return;
}
if (localTemplateSets[newTemplateId]) {
setAnalysisSaveMessage('模板 ID 已存在');
return;
}
const newSet: AnalysisTemplateSets = {
...localTemplateSets,
[newTemplateId]: {
name: newTemplateName,
modules: {},
},
};
setLocalTemplateSets(newSet);
setSelectedTemplateId(newTemplateId);
setNewTemplateId('');
setNewTemplateName('');
setIsCreatingTemplate(false);
// 新建后立即持久化
(async () => {
setIsSavingAnalysis(true);
setAnalysisSaveMessage('保存中...');
try {
const updated = await updateAnalysisTemplateSets(newSet);
await mutateAnalysisTemplateSets(updated, false);
setAnalysisSaveMessage('分析配置保存成功!');
} catch (e: any) {
setAnalysisSaveMessage(`保存失败: ${e?.message || '未知错误'}`);
} finally {
setIsSavingAnalysis(false);
setTimeout(() => setAnalysisSaveMessage(''), 5000);
}
})();
};
const handleDeleteTemplate = () => {
if (!selectedTemplateId || !window.confirm(`确定要删除模板 "${localTemplateSets[selectedTemplateId].name}" 吗?`)) {
return;
}
const newSets = { ...localTemplateSets };
delete newSets[selectedTemplateId];
setLocalTemplateSets(newSets);
const firstKey = Object.keys(newSets)[0] || null;
setSelectedTemplateId(firstKey);
};
const handleAddNewModule = () => {
if (!selectedTemplateId || !newModuleId || !newModuleName) {
setAnalysisSaveMessage('模块 ID 和名称不能为空');
return;
}
if (localTemplateSets[selectedTemplateId].modules[newModuleId]) {
setAnalysisSaveMessage('模块 ID 已存在');
return;
}
setLocalTemplateSets(prev => ({
...prev,
[selectedTemplateId]: {
...prev[selectedTemplateId],
modules: {
...prev[selectedTemplateId].modules,
[newModuleId]: {
name: newModuleName,
provider_id: '',
model_id: '',
prompt_template: '',
dependencies: [],
}
}
}
}));
setNewModuleId('');
setNewModuleName('');
setIsCreatingModule(false);
};
const handleDeleteModule = (moduleId: string) => {
if (!selectedTemplateId) return;
setLocalTemplateSets(prev => {
const newModules = { ...prev[selectedTemplateId].modules };
delete newModules[moduleId];
return {
...prev,
[selectedTemplateId]: {
...prev[selectedTemplateId],
modules: newModules,
},
};
});
};
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* --- Level 1: Template Set Management --- */}
<div className="p-4 border rounded-lg bg-slate-50 space-y-4">
<div className="flex items-center gap-4">
<Label className="font-semibold">:</Label>
<Select
value={selectedTemplateId || ''}
onValueChange={(id) => setSelectedTemplateId(id)}
>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="选择一个模板..." />
</SelectTrigger>
<SelectContent>
{Object.entries(localTemplateSets).map(([id, set]) => (
<SelectItem key={id} value={id}>{set.name} ({id})</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={() => setIsCreatingTemplate(true)} disabled={isCreatingTemplate}>+ </Button>
<Button variant="destructive" onClick={handleDeleteTemplate} disabled={!selectedTemplateId}></Button>
</div>
{isCreatingTemplate && (
<div className="space-y-3 p-3 border rounded-md border-dashed">
<h4 className="font-semibold"></h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
placeholder="模板 ID (e.g., standard_v2)"
value={newTemplateId}
onChange={(e) => setNewTemplateId(e.target.value.replace(/\s/g, ''))}
/>
<Input
placeholder="模板名称 (e.g., 标准分析模板V2)"
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
/>
</div> </div>
<div className="flex gap-2">
<Button onClick={handleAddTemplate}></Button>
<Button variant="ghost" onClick={() => setIsCreatingTemplate(false)}></Button>
</div>
</div>
)}
</div>
<Separator /> <Separator />
{/* --- Level 2: Module Management (within selected template) --- */} {/* --- Level 2: Module Management (within selected template) --- */}
{selectedTemplateId && localTemplateSets[selectedTemplateId] ? ( {selectedTemplateId && localTemplateSets[selectedTemplateId] ? (
<div className="space-y-6"> <div className="space-y-6">
{Object.entries(localTemplateSets[selectedTemplateId].modules).map(([moduleId, config]) => { {Object.entries(localTemplateSets[selectedTemplateId].modules).map(([moduleId, config]) => {
const allModulesInSet = localTemplateSets[selectedTemplateId]!.modules; const allModulesInSet = localTemplateSets[selectedTemplateId].modules;
return ( return (
<div key={moduleId} className="space-y-4 p-4 border rounded-lg"> <div key={moduleId} className="space-y-4 p-4 border rounded-lg">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-lg font-semibold">{config.name || moduleId}</h3> <h3 className="text-lg font-semibold">{config.name || moduleId}</h3>
<p className="text-sm text-muted-foreground">ID: <Badge variant="secondary">{moduleId}</Badge></p> <p className="text-sm text-muted-foreground">ID: <Badge variant="secondary">{moduleId}</Badge></p>
</div>
<Button variant="destructive" size="sm" onClick={() => handleDeleteModule(moduleId)}></Button>
</div>
<div className="space-y-2">
<Label>Model (Provider)</Label>
<ModelSelector
value={{ providerId: config.provider_id, modelId: config.model_id }}
onChange={(pId, mId) => {
handleAnalysisChange(moduleId, 'provider_id', pId);
handleAnalysisChange(moduleId, 'model_id', mId);
}}
allModels={allModels}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${moduleId}-prompt`}></Label>
<Textarea
id={`${moduleId}-prompt`}
value={config.prompt_template || ''}
onChange={(e) => handleAnalysisChange(moduleId, 'prompt_template', e.target.value)}
rows={10}
/>
</div>
<div className="space-y-2">
<Label> (Dependencies)</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-2 border rounded-md">
{Object.keys(allModulesInSet)
.filter(id => id !== moduleId)
.map(depId => (
<div key={depId} className="flex items-center space-x-2">
<Checkbox
id={`${moduleId}-${depId}`}
checked={(config.dependencies || []).includes(depId)}
onCheckedChange={(checked) => updateAnalysisDependencies(moduleId, depId, !!checked)}
/>
<label
htmlFor={`${moduleId}-${depId}`}
className="text-sm font-medium"
>
{allModulesInSet[depId]?.name || depId}
</label>
</div>
))}
</div>
</div>
</div>
);
})}
{isCreatingModule && (
<div className="space-y-4 p-4 border rounded-lg border-dashed">
<h3 className="text-lg font-semibold"> "{localTemplateSets[selectedTemplateId].name}" </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="new-module-id"> ID (, )</Label>
<Input
id="new-module-id"
value={newModuleId}
onChange={(e) => setNewModuleId(e.target.value.replace(/\s/g, ''))}
placeholder="e.g. fundamental_analysis"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-module-name"></Label>
<Input
id="new-module-name"
value={newModuleName}
onChange={(e) => setNewModuleName(e.target.value)}
placeholder="e.g. 基本面分析"
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleAddNewModule}></Button>
<Button variant="ghost" onClick={() => setIsCreatingModule(false)}></Button>
</div>
</div> </div>
)} <Button variant="destructive" size="sm" onClick={() => handleDeleteModule(moduleId)}></Button>
</div>
<div className="flex items-center gap-4 pt-4 border-t"> <div className="space-y-2">
<Button onClick={() => setIsCreatingModule(true)} variant="outline" disabled={isCreatingModule}> <Label>Model (Provider)</Label>
+ <ModelSelector
</Button> value={{ providerId: config.provider_id, modelId: config.model_id }}
<Button onClick={handleSaveAnalysis} disabled={isSavingAnalysis}> onChange={(pId, mId) => {
{isSavingAnalysis ? '保存中...' : '保存所有变更'} handleAnalysisChange(moduleId, 'provider_id', pId);
</Button> handleAnalysisChange(moduleId, 'model_id', mId);
{analysisSaveMessage && ( }}
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}> allModels={allModels}
{analysisSaveMessage} />
</span> </div>
)}
<div className="space-y-2">
<Label htmlFor={`${moduleId}-prompt`}></Label>
<Textarea
id={`${moduleId}-prompt`}
value={config.prompt_template || ''}
onChange={(e) => handleAnalysisChange(moduleId, 'prompt_template', e.target.value)}
rows={10}
/>
</div>
<div className="space-y-2">
<Label> (Dependencies)</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-2 border rounded-md">
{Object.keys(allModulesInSet)
.filter(id => id !== moduleId)
.map(depId => (
<div key={depId} className="flex items-center space-x-2">
<Checkbox
id={`${moduleId}-${depId}`}
checked={(config.dependencies || []).includes(depId)}
onCheckedChange={(checked) => updateAnalysisDependencies(moduleId, depId, !!checked)}
/>
<label
htmlFor={`${moduleId}-${depId}`}
className="text-sm font-medium"
>
{allModulesInSet[depId]?.name || depId}
</label>
</div>
))}
</div>
</div> </div>
</div> </div>
) : ( );
<div className="text-center text-muted-foreground py-10"> })}
<p></p>
{isCreatingModule && (
<div className="space-y-4 p-4 border rounded-lg border-dashed">
<h3 className="text-lg font-semibold"> "{localTemplateSets[selectedTemplateId].name}" </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="new-module-id"> ID (, )</Label>
<Input
id="new-module-id"
value={newModuleId}
onChange={(e) => setNewModuleId(e.target.value.replace(/\s/g, ''))}
placeholder="e.g. fundamental_analysis"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-module-name"></Label>
<Input
id="new-module-name"
value={newModuleName}
onChange={(e) => setNewModuleName(e.target.value)}
placeholder="e.g. 基本面分析"
/>
</div>
</div> </div>
<div className="flex gap-2">
<Button onClick={handleAddNewModule}></Button>
<Button variant="ghost" onClick={() => setIsCreatingModule(false)}></Button>
</div>
</div>
)}
<div className="flex items-center gap-4 pt-4 border-t">
<Button onClick={() => setIsCreatingModule(true)} variant="outline" disabled={isCreatingModule}>
+
</Button>
<Button onClick={handleSaveAnalysis} disabled={isSavingAnalysis}>
{isSavingAnalysis ? '保存中...' : '保存所有变更'}
</Button>
{analysisSaveMessage && (
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
{analysisSaveMessage}
</span>
)} )}
</CardContent> </div>
</Card> </div>
</div> ) : (
); <div className="text-center text-muted-foreground py-10">
<p></p>
</div>
)}
</CardContent>
</Card>
);
} }

View File

@ -0,0 +1,159 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { formatDetailsToYaml } from '../utils';
import type { DataSourcesConfig, DataSourceConfig, DataSourceProvider } from '@/types';
interface DataSourcesConfigTabProps {
dsLoading: boolean;
dsError: any;
localDataSources: DataSourcesConfig;
setLocalDataSources: React.Dispatch<React.SetStateAction<DataSourcesConfig>>;
testResults: Record<string, { success: boolean; summary: string; details?: string } | null>;
handleTestTushare: () => void;
handleTestFinnhub: () => void;
handleTestAlphaVantage: () => void;
}
const defaultUrls: Partial<Record<DataSourceProvider, string>> = {
tushare: 'http://api.tushare.pro',
finnhub: 'https://finnhub.io/api/v1',
alphavantage: 'https://mcp.alphavantage.co/mcp',
};
export function DataSourcesConfigTab({
dsLoading,
dsError,
localDataSources,
setLocalDataSources,
testResults,
handleTestTushare,
handleTestFinnhub,
handleTestAlphaVantage,
}: DataSourcesConfigTabProps) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{dsLoading && <div className="text-sm text-muted-foreground">...</div>}
{dsError && <div className="text-sm text-red-600">: {String(dsError)}</div>}
{!dsLoading && !dsError && (
<div className="space-y-6">
{(['tushare','finnhub','alphavantage','yfinance'] as DataSourceProvider[]).map((providerKey) => {
const item: DataSourceConfig = localDataSources[providerKey] || {
provider: providerKey,
api_key: '',
api_url: '',
enabled: false,
};
return (
<div key={providerKey} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium capitalize">{providerKey}</Label>
<p className="text-sm text-muted-foreground">
{providerKey === 'tushare' && '中国市场数据源'}
{providerKey === 'finnhub' && '全球市场数据源'}
{providerKey === 'alphavantage' && '全球市场数据源MCP桥接'}
{providerKey === 'yfinance' && '雅虎财经数据源'}
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${providerKey}-enabled`}
checked={!!item.enabled}
onCheckedChange={(checked) => {
setLocalDataSources((prev) => ({
...prev,
[providerKey]: { ...item, enabled: !!checked, provider: providerKey },
}));
}}
/>
<label htmlFor={`${providerKey}-enabled`} className="text-sm"></label>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`${providerKey}-api-key`}>API Key</Label>
<Input
id={`${providerKey}-api-key`}
type="password"
value={item.api_key ?? ''}
onChange={(e) => {
const v = e.target.value;
setLocalDataSources((prev) => ({
...prev,
[providerKey]: { ...item, api_key: v, provider: providerKey },
}));
}}
placeholder="留空表示清空或保持(根据启用状态)"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${providerKey}-api-url`}>API URL</Label>
<Input
id={`${providerKey}-api-url`}
type="text"
value={item.api_url ?? ''}
onChange={(e) => {
const v = e.target.value;
setLocalDataSources((prev) => ({
...prev,
[providerKey]: { ...item, api_url: v, provider: providerKey },
}));
}}
placeholder={defaultUrls[providerKey] ?? 'https://...'}
disabled={providerKey === 'yfinance'}
/>
</div>
</div>
<div className="flex gap-2">
{providerKey === 'tushare' && (
<Button variant="outline" onClick={handleTestTushare}> Tushare</Button>
)}
{providerKey === 'finnhub' && (
<Button variant="outline" onClick={handleTestFinnhub}> Finnhub</Button>
)}
{providerKey === 'alphavantage' && (
<Button variant="outline" onClick={handleTestAlphaVantage}> AlphaVantage</Button>
)}
</div>
{testResults[providerKey] ? (() => {
const r = testResults[providerKey]!;
if (r.success) {
return (
<div className="text-sm text-green-600">
{r.summary}
</div>
);
}
return (
<div className="text-sm text-red-600">
<div className="font-medium"></div>
{r.details ? (
<details className="mt-2">
<summary className="cursor-pointer text-red-700 underline">YAML</summary>
<pre className="mt-2 p-2 rounded bg-red-50 text-red-700 whitespace-pre-wrap break-words">
{formatDetailsToYaml(r.details)}
</pre>
</details>
) : null}
</div>
);
})() : null}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -1,178 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useDataSourcesConfigLogic } from '../hooks/useDataSourcesConfigLogic';
import { formatDetailsToYaml } from '../utils/yaml-helper';
import { DataSourceProvider, DataSourceConfig } from '@/types';
export function DataSourcesTab() {
const {
localDataSources,
setLocalDataSources,
dsLoading,
dsError,
saving,
saveMessage,
testResults,
handleSave,
handleReset,
handleTest,
defaultUrls
} = useDataSourcesConfigLogic();
const handleTestTushare = () => {
const cfg = localDataSources['tushare'];
handleTest('tushare', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
const handleTestFinnhub = () => {
const cfg = localDataSources['finnhub'];
handleTest('finnhub', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
const handleTestAlphaVantage = () => {
const cfg = localDataSources['alphavantage'];
handleTest('alphavantage', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{dsLoading && <div className="text-sm text-muted-foreground">...</div>}
{dsError && <div className="text-sm text-red-600">: {String(dsError)}</div>}
{!dsLoading && !dsError && (
<div className="space-y-6">
{(['tushare','finnhub','alphavantage','yfinance'] as DataSourceProvider[]).map((providerKey) => {
const item: DataSourceConfig = localDataSources[providerKey] || {
provider: providerKey,
api_key: '',
api_url: '',
enabled: false,
};
return (
<div key={providerKey} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium capitalize">{providerKey}</Label>
<p className="text-sm text-muted-foreground">
{providerKey === 'tushare' && '中国市场数据源'}
{providerKey === 'finnhub' && '全球市场数据源'}
{providerKey === 'alphavantage' && '全球市场数据源MCP桥接'}
{providerKey === 'yfinance' && '雅虎财经数据源'}
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${providerKey}-enabled`}
checked={!!item.enabled}
onCheckedChange={(checked) => {
setLocalDataSources((prev) => ({
...prev,
[providerKey]: { ...item, enabled: !!checked, provider: providerKey },
}));
}}
/>
<label htmlFor={`${providerKey}-enabled`} className="text-sm"></label>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`${providerKey}-api-key`}>API Key</Label>
<Input
id={`${providerKey}-api-key`}
type="password"
value={item.api_key ?? ''}
onChange={(e) => {
const v = e.target.value;
setLocalDataSources((prev) => ({
...prev,
[providerKey]: { ...item, api_key: v, provider: providerKey },
}));
}}
placeholder="留空表示清空或保持(根据启用状态)"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${providerKey}-api-url`}>API URL</Label>
<Input
id={`${providerKey}-api-url`}
type="text"
value={item.api_url ?? ''}
onChange={(e) => {
const v = e.target.value;
setLocalDataSources((prev) => ({
...prev,
[providerKey]: { ...item, api_url: v, provider: providerKey },
}));
}}
placeholder={defaultUrls[providerKey] ?? 'https://...'}
disabled={providerKey === 'yfinance'}
/>
</div>
</div>
<div className="flex gap-2">
{providerKey === 'tushare' && (
<Button variant="outline" onClick={handleTestTushare}> Tushare</Button>
)}
{providerKey === 'finnhub' && (
<Button variant="outline" onClick={handleTestFinnhub}> Finnhub</Button>
)}
{providerKey === 'alphavantage' && (
<Button variant="outline" onClick={handleTestAlphaVantage}> AlphaVantage</Button>
)}
</div>
{testResults[providerKey] ? (() => {
const r = testResults[providerKey]!;
if (r.success) {
return (
<div className="text-sm text-green-600">
{r.summary}
</div>
);
}
return (
<div className="text-sm text-red-600">
<div className="font-medium"></div>
{r.details ? (
<details className="mt-2">
<summary className="cursor-pointer text-red-700 underline">YAML</summary>
<pre className="mt-2 p-2 rounded bg-red-50 text-red-700 whitespace-pre-wrap break-words">
{formatDetailsToYaml(r.details)}
</pre>
</details>
) : null}
</div>
);
})() : null}
</div>
);
})}
</div>
)}
<div className="flex items-center gap-4 pt-4 border-t">
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存数据源配置'}
</Button>
<Button onClick={handleReset} variant="outline">
</Button>
{saveMessage && (
<span className={`text-sm ${saveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
{saveMessage}
</span>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,9 +1,8 @@
'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react"; import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@ -19,7 +18,7 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { LlmModel } from '@/types'; import type { LlmModel } from '@/types';
export interface ModelSelectorProps { interface ModelSelectorProps {
value: { providerId: string; modelId: string }; value: { providerId: string; modelId: string };
onChange: (providerId: string, modelId: string) => void; onChange: (providerId: string, modelId: string) => void;
allModels: { providerId: string; providerName: string; model: LlmModel }[]; allModels: { providerId: string; providerName: string; model: LlmModel }[];

View File

@ -1,87 +1,128 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useSystemConfig } from '../hooks/useSystemConfig'; import { Label } from "@/components/ui/label";
import type { SystemConfig } from '@/stores/useConfigStore';
interface SystemConfigTabProps { interface SystemConfigTabProps {
onImportSuccess?: (config: any) => void; config: SystemConfig | null;
setSaveMessage: (msg: string) => void;
setNewProviderBaseUrl: (url: string) => void;
} }
export function SystemConfigTab({ onImportSuccess }: SystemConfigTabProps) { export function SystemConfigTab({ config, setSaveMessage, setNewProviderBaseUrl }: SystemConfigTabProps) {
const { config, saveMessage, handleExportConfig, handleImportConfig } = useSystemConfig();
return ( const handleExportConfig = () => {
<div className="space-y-4"> if (!config) return;
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>New API</Label>
<Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>
{config?.new_api?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2">
<Label>Tushare API</Label>
<Badge variant={config?.data_sources?.tushare?.api_key ? 'default' : 'secondary'}>
{config?.data_sources?.tushare?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2">
<Label>Finnhub API</Label>
<Badge variant={config?.data_sources?.finnhub?.api_key ? 'default' : 'secondary'}>
{config?.data_sources?.finnhub?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card> const configToExport = {
<CardHeader> new_api: config.new_api,
<CardTitle></CardTitle> data_sources: config.data_sources,
<CardDescription></CardDescription> export_time: new Date().toISOString(),
</CardHeader> version: "1.0"
<CardContent className="space-y-4"> };
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={handleExportConfig} variant="outline" className="flex-1"> const blob = new Blob([JSON.stringify(configToExport, null, 2)], { type: 'application/json' });
📤 const url = URL.createObjectURL(blob);
</Button> const a = document.createElement('a');
<div className="flex-1"> a.href = url;
<input a.download = `config-backup-${new Date().toISOString().split('T')[0]}.json`;
type="file" document.body.appendChild(a);
accept=".json" a.click();
onChange={(e) => handleImportConfig(e, onImportSuccess)} document.body.removeChild(a);
className="hidden" URL.revokeObjectURL(url);
id="import-config" };
/>
<Button const handleImportConfig = (event: React.ChangeEvent<HTMLInputElement>) => {
variant="outline" const file = event.target.files?.[0];
className="w-full" if (!file) return;
onClick={() => document.getElementById('import-config')?.click()}
> const reader = new FileReader();
📥 reader.onload = (e) => {
</Button> try {
</div> const importedConfig = JSON.parse(e.target?.result as string);
</div>
<div className="text-sm text-muted-foreground"> // 验证导入的配置格式
<p> </p> if (importedConfig.new_api?.base_url) {
<p> </p> setNewProviderBaseUrl(importedConfig.new_api.base_url);
<p> </p> }
</div>
{saveMessage && ( setSaveMessage('配置导入成功,请检查并保存');
<div className={`text-sm ${saveMessage.includes('失败') ? 'text-red-600' : 'text-green-600'}`}> } catch (error) {
{saveMessage} setSaveMessage('配置文件格式错误,导入失败');
</div> }
)} };
</CardContent> reader.readAsText(file);
</Card> };
</div>
); return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>New API</Label>
<Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>
{config?.new_api?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2">
<Label>Tushare API</Label>
<Badge variant={config?.data_sources?.tushare?.api_key ? 'default' : 'secondary'}>
{config?.data_sources?.tushare?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
<div className="space-y-2">
<Label>Finnhub API</Label>
<Badge variant={config?.data_sources?.finnhub?.api_key ? 'default' : 'secondary'}>
{config?.data_sources?.finnhub?.api_key ? '已配置' : '未配置'}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={handleExportConfig} variant="outline" className="flex-1">
📤
</Button>
<div className="flex-1">
<input
type="file"
accept=".json"
onChange={handleImportConfig}
className="hidden"
id="import-config"
/>
<Button
variant="outline"
className="w-full"
onClick={() => document.getElementById('import-config')?.click()}
>
📥
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground">
<p> </p>
<p> </p>
<p> </p>
</div>
</CardContent>
</Card>
</div>
);
} }

View File

@ -1,17 +1,122 @@
'use client'; 'use client';
import { useConfig } from '@/hooks/useApi'; import { useState, useEffect } from 'react';
import { useConfig, testConfig, useDataSourcesConfig, updateDataSourcesConfig } from '@/hooks/useApi';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AiConfigTab } from './components/AiConfigTab'; import { Button } from "@/components/ui/button";
import { DataSourcesTab } from './components/DataSourcesTab'; import type { DataSourcesConfig, DataSourceProvider } from '@/types';
import { AIConfigTab } from './components/AIConfigTab';
import { DataSourcesConfigTab } from './components/DataSourcesConfigTab';
import { AnalysisConfigTab } from './components/AnalysisConfigTab'; import { AnalysisConfigTab } from './components/AnalysisConfigTab';
import { SystemConfigTab } from './components/SystemConfigTab'; import { SystemConfigTab } from './components/SystemConfigTab';
const defaultUrls: Partial<Record<DataSourceProvider, string>> = {
tushare: 'http://api.tushare.pro',
finnhub: 'https://finnhub.io/api/v1',
alphavantage: 'https://mcp.alphavantage.co/mcp',
};
export default function ConfigPage() { export default function ConfigPage() {
const { config, loading, error } = useConfigStore(); // 从 Zustand store 获取全局状态
const { config, loading, error, setConfig } = useConfigStore();
// 使用 SWR hook 加载初始配置
useConfig(); useConfig();
// 本地表单状态
// 数据源本地状态
const { data: initialDataSources, error: dsError, isLoading: dsLoading, mutate: mutateDataSources } = useDataSourcesConfig();
const [localDataSources, setLocalDataSources] = useState<DataSourcesConfig>({});
// 测试结果状态
const [testResults, setTestResults] = useState<Record<string, { success: boolean; summary: string; details?: string } | null>>({});
// 保存状态
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
// Shared state for AI Tab (needed for System Tab's import feature)
const [newProviderBaseUrl, setNewProviderBaseUrl] = useState('');
useEffect(() => {
if (initialDataSources) {
setLocalDataSources(initialDataSources);
}
}, [initialDataSources]);
const handleSave = async () => {
setSaving(true);
setSaveMessage('保存中...');
try {
if (initialDataSources) {
// Create a deep copy to avoid mutating the local state directly
const finalDataSources = JSON.parse(JSON.stringify(localDataSources));
for (const key in finalDataSources) {
const providerKey = key as DataSourceProvider;
const source = finalDataSources[providerKey];
// If the URL is empty or null, and there is a default URL, use it
if (source && (source.api_url === null || source.api_url.trim() === '') && defaultUrls[providerKey]) {
source.api_url = defaultUrls[providerKey];
}
}
await updateDataSourcesConfig(finalDataSources);
// After saving, mutate the local SWR cache with the final data
// and also update the component's local state to reflect the change.
await mutateDataSources(finalDataSources, false);
setLocalDataSources(finalDataSources);
}
setSaveMessage('保存成功!');
} catch (e: any) {
setSaveMessage(`保存失败: ${e.message}`);
} finally {
setSaving(false);
setTimeout(() => setSaveMessage(''), 5000);
}
};
const handleTest = async (type: string, data: any) => {
try {
const result = await testConfig(type, data);
const success = !!result?.success;
const summary = typeof result?.message === 'string' && result.message.trim().length > 0
? result.message
: (success ? '测试成功' : '测试失败');
setTestResults(prev => ({ ...prev, [type]: { success, summary } }));
} catch (e: any) {
// 结构化错误对象:{ summary, details? }
const summary: string = (e && typeof e === 'object' && 'summary' in e) ? String(e.summary) : (e?.message || '未知错误');
const details: string | undefined = (e && typeof e === 'object' && 'details' in e) ? (e.details ? String(e.details) : undefined) : undefined;
setTestResults(prev => ({
...prev,
[type]: { success: false, summary, details }
}));
}
};
const handleTestTushare = () => {
const cfg = localDataSources['tushare'];
handleTest('tushare', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
const handleTestFinnhub = () => {
const cfg = localDataSources['finnhub'];
handleTest('finnhub', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
const handleTestAlphaVantage = () => {
const cfg = localDataSources['alphavantage'];
handleTest('alphavantage', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
};
const handleReset = () => {
if (initialDataSources) setLocalDataSources(initialDataSources);
setTestResults({});
setSaveMessage('');
};
if (loading) return ( if (loading) return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
@ -48,11 +153,23 @@ export default function ConfigPage() {
</TabsList> </TabsList>
<TabsContent value="ai" className="space-y-4"> <TabsContent value="ai" className="space-y-4">
<AiConfigTab /> <AIConfigTab
newProviderBaseUrl={newProviderBaseUrl}
setNewProviderBaseUrl={setNewProviderBaseUrl}
/>
</TabsContent> </TabsContent>
<TabsContent value="data-sources" className="space-y-4"> <TabsContent value="data-sources" className="space-y-4">
<DataSourcesTab /> <DataSourcesConfigTab
dsLoading={dsLoading}
dsError={dsError}
localDataSources={localDataSources}
setLocalDataSources={setLocalDataSources}
testResults={testResults}
handleTestTushare={handleTestTushare}
handleTestFinnhub={handleTestFinnhub}
handleTestAlphaVantage={handleTestAlphaVantage}
/>
</TabsContent> </TabsContent>
<TabsContent value="analysis" className="space-y-4"> <TabsContent value="analysis" className="space-y-4">
@ -60,9 +177,32 @@ export default function ConfigPage() {
</TabsContent> </TabsContent>
<TabsContent value="system" className="space-y-4"> <TabsContent value="system" className="space-y-4">
<SystemConfigTab /> <SystemConfigTab
config={config}
setSaveMessage={setSaveMessage}
setNewProviderBaseUrl={setNewProviderBaseUrl}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<div className="flex items-center justify-between pt-6 border-t">
<div className="flex items-center gap-4">
<Button onClick={handleSave} disabled={saving} size="lg">
{saving ? '保存中...' : '保存所有配置'}
</Button>
<Button onClick={handleReset} variant="outline" size="lg">
</Button>
{saveMessage && (
<span className={`text-sm ${saveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
{saveMessage}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
: {new Date().toLocaleString()}
</div>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,139 @@
// ---- Helpers: pretty print nested JSON as YAML-like (braceless, unquoted) ----
const MAX_REPARSE_DEPTH = 2;
function tryParseJson(input: string): unknown {
try {
return JSON.parse(input);
} catch {
return input;
}
}
function parsePossiblyNestedJson(raw: string): unknown {
let current: unknown = raw;
for (let i = 0; i < MAX_REPARSE_DEPTH; i++) {
if (typeof current === 'string') {
const trimmed = current.trim();
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
current = tryParseJson(trimmed);
continue;
}
}
break;
}
return current;
}
function indentLines(text: string, indent: string): string {
return text.split('\n').map((line) => indent + line).join('\n');
}
function looksLikeStructuredText(s: string): boolean {
const t = s.trim();
// 具有成对的大括号,或常见的 Rust/reqwest 错误标识,判定为可结构化的调试输出
return (t.includes('{') && t.includes('}')) || t.includes('DynamicTransportError') || t.includes('reqwest::Error');
}
function prettyFormatByBraces(input: string): string {
let out = '';
let indentLevel = 0;
const indentUnit = ' ';
let inString = false;
let stringQuote = '';
for (let i = 0; i < input.length; i++) {
const ch = input[i]!;
const prev = i > 0 ? input[i - 1]! : '';
if ((ch === '"' || ch === "'") && prev !== '\\') {
if (!inString) {
inString = true;
stringQuote = ch;
} else if (stringQuote === ch) {
inString = false;
}
out += ch;
continue;
}
if (inString) {
out += ch;
continue;
}
if (ch === '{' || ch === '[' || ch === '(') {
indentLevel += 1;
out += ch + '\n' + indentUnit.repeat(indentLevel);
continue;
}
if (ch === '}' || ch === ']' || ch === ')') {
indentLevel = Math.max(0, indentLevel - 1);
out += '\n' + indentUnit.repeat(indentLevel) + ch;
continue;
}
if (ch === ',') {
out += ch + '\n' + indentUnit.repeat(indentLevel);
continue;
}
if (ch === ':') {
out += ch + ' ';
continue;
}
out += ch;
}
return out;
}
export function toPseudoYaml(value: unknown, indent: string = ''): string {
const nextIndent = indent + ' ';
if (value === null || value === undefined) {
return `${indent}null`;
}
const t = typeof value;
if (t === 'string' || t === 'number' || t === 'boolean') {
const s = String(value);
const shouldPretty = looksLikeStructuredText(s) || s.includes('\n');
if (shouldPretty) {
const pretty = looksLikeStructuredText(s) ? prettyFormatByBraces(s) : s;
return `${indent}|-\n${indentLines(pretty, nextIndent)}`;
}
return `${indent}${s}`;
}
if (Array.isArray(value)) {
if (value.length === 0) return `${indent}[]`;
return value.map((item) => {
const rendered = toPseudoYaml(item, nextIndent);
const lines = rendered.split('\n');
if (lines.length === 1) {
return `${indent}- ${lines[0].trimStart()}`;
}
return `${indent}- ${lines[0].trimStart()}\n${lines.slice(1).map((l) => indent + ' ' + l.trimStart()).join('\n')}`;
}).join('\n');
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) return `${indent}{}`;
return keys.map((k) => {
const rendered = toPseudoYaml(obj[k], nextIndent);
const lines = rendered.split('\n');
if (lines.length === 1) {
return `${indent}${k}: ${lines[0].trimStart()}`;
}
return `${indent}${k}:\n${lines.map((l) => l).join('\n')}`;
}).join('\n');
}
// fallback
return `${indent}${String(value)}`;
}
export function formatDetailsToYaml(details: string): string {
const parsed = parsePossiblyNestedJson(details);
if (typeof parsed === 'string') {
// 尝试再次解析(有些场景内层 message 也是 JSON 串)
const again = parsePossiblyNestedJson(parsed);
if (typeof again === 'string') {
return toPseudoYaml(again);
}
return toPseudoYaml(again);
}
return toPseudoYaml(parsed);
}