Refactor config page: split into smaller components and hooks
This commit is contained in:
parent
68ae2656a7
commit
75378e7aae
487
frontend/src/app/config/components/AiConfigTab.tsx
Normal file
487
frontend/src/app/config/components/AiConfigTab.tsx
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
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>管理多个 Provider(API Key、Base 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
213
frontend/src/app/config/components/AnalysisConfigTab.tsx
Normal file
213
frontend/src/app/config/components/AnalysisConfigTab.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
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 { 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 { useAnalysisConfig } from '../hooks/useAnalysisConfig';
|
||||||
|
import { ModelSelector } from './ModelSelector';
|
||||||
|
|
||||||
|
export function AnalysisConfigTab() {
|
||||||
|
const {
|
||||||
|
localTemplateSets,
|
||||||
|
selectedTemplateId,
|
||||||
|
setSelectedTemplateId,
|
||||||
|
allModels,
|
||||||
|
isSavingAnalysis,
|
||||||
|
analysisSaveMessage,
|
||||||
|
setAnalysisSaveMessage,
|
||||||
|
|
||||||
|
newTemplateId, setNewTemplateId,
|
||||||
|
newTemplateName, setNewTemplateName,
|
||||||
|
isCreatingTemplate, setIsCreatingTemplate,
|
||||||
|
handleAddTemplate,
|
||||||
|
handleDeleteTemplate,
|
||||||
|
|
||||||
|
isCreatingModule, setIsCreatingModule,
|
||||||
|
newModuleId, setNewModuleId,
|
||||||
|
newModuleName, setNewModuleName,
|
||||||
|
handleAddNewModule,
|
||||||
|
handleDeleteModule,
|
||||||
|
|
||||||
|
handleAnalysisChange,
|
||||||
|
handleSaveAnalysis,
|
||||||
|
updateAnalysisDependencies
|
||||||
|
} = useAnalysisConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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 className="flex gap-2">
|
||||||
|
<Button onClick={handleAddTemplate}>确认创建</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setIsCreatingTemplate(false)}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* --- Level 2: Module Management (within selected template) --- */}
|
||||||
|
{selectedTemplateId && localTemplateSets[selectedTemplateId] ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(localTemplateSets[selectedTemplateId].modules).map(([moduleId, config]) => {
|
||||||
|
const allModulesInSet = localTemplateSets[selectedTemplateId]!.modules;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={moduleId} className="space-y-4 p-4 border rounded-lg">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold">{config.name || moduleId}</h3>
|
||||||
|
<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 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-10">
|
||||||
|
<p>请先选择或创建一个分析模板集。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
178
frontend/src/app/config/components/DataSourcesTab.tsx
Normal file
178
frontend/src/app/config/components/DataSourcesTab.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
86
frontend/src/app/config/components/ModelSelector.tsx
Normal file
86
frontend/src/app/config/components/ModelSelector.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import type { LlmModel } from '@/types';
|
||||||
|
|
||||||
|
export interface ModelSelectorProps {
|
||||||
|
value: { providerId: string; modelId: string };
|
||||||
|
onChange: (providerId: string, modelId: string) => void;
|
||||||
|
allModels: { providerId: string; providerName: string; model: LlmModel }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({ value, onChange, allModels }: ModelSelectorProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedModel = allModels.find(
|
||||||
|
(m) => m.providerId === value.providerId && m.model.model_id === value.modelId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{selectedModel
|
||||||
|
? `${selectedModel.model.name || selectedModel.model.model_id} (${selectedModel.providerName})`
|
||||||
|
: (value.modelId ? `${value.modelId}` : "选择模型")}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[400px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="搜索模型..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>未找到模型。</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allModels.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${item.providerId}-${item.model.model_id}`}
|
||||||
|
value={`${item.model.name || item.model.model_id} ${item.providerName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(item.providerId, item.model.model_id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value.modelId === item.model.model_id && value.providerId === item.providerId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.model.name || item.model.model_id}
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({item.providerName})
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
87
frontend/src/app/config/components/SystemConfigTab.tsx
Normal file
87
frontend/src/app/config/components/SystemConfigTab.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useSystemConfig } from '../hooks/useSystemConfig';
|
||||||
|
|
||||||
|
interface SystemConfigTabProps {
|
||||||
|
onImportSuccess?: (config: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemConfigTab({ onImportSuccess }: SystemConfigTabProps) {
|
||||||
|
const { config, saveMessage, handleExportConfig, handleImportConfig } = useSystemConfig();
|
||||||
|
|
||||||
|
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={(e) => handleImportConfig(e, onImportSuccess)}
|
||||||
|
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>
|
||||||
|
{saveMessage && (
|
||||||
|
<div className={`text-sm ${saveMessage.includes('失败') ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{saveMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
208
frontend/src/app/config/hooks/useAiConfig.ts
Normal file
208
frontend/src/app/config/hooks/useAiConfig.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
useLlmProviders,
|
||||||
|
updateLlmProviders,
|
||||||
|
discoverProviderModels
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import { LlmProvidersConfig, LlmModel } from '@/types';
|
||||||
|
|
||||||
|
export function useAiConfig() {
|
||||||
|
const { data: llmProviders, mutate: mutateLlmProviders } = useLlmProviders();
|
||||||
|
const [localLlmProviders, setLocalLlmProviders] = useState<LlmProvidersConfig>({});
|
||||||
|
|
||||||
|
const [isSavingLlm, setIsSavingLlm] = useState(false);
|
||||||
|
const [llmSaveMessage, setLlmSaveMessage] = useState('');
|
||||||
|
|
||||||
|
// New Provider Form
|
||||||
|
const [newProviderId, setNewProviderId] = useState('');
|
||||||
|
const [newProviderBaseUrl, setNewProviderBaseUrl] = useState('');
|
||||||
|
const [newProviderApiKey, setNewProviderApiKey] = useState('');
|
||||||
|
|
||||||
|
// Provider states
|
||||||
|
const [pendingApiKeys, setPendingApiKeys] = useState<Record<string, string>>({});
|
||||||
|
const [editingApiKey, setEditingApiKey] = useState<Record<string, boolean>>({});
|
||||||
|
const [discoverMessages, setDiscoverMessages] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Model management states
|
||||||
|
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 manual input states
|
||||||
|
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>>({});
|
||||||
|
|
||||||
|
// Auto-save refs
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Sync with server data initially
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const handleAddProvider = () => {
|
||||||
|
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('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscoverModels = async (providerId: string) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
228
frontend/src/app/config/hooks/useAnalysisConfig.ts
Normal file
228
frontend/src/app/config/hooks/useAnalysisConfig.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useAnalysisTemplateSets,
|
||||||
|
updateAnalysisTemplateSets,
|
||||||
|
useLlmProviders
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import { AnalysisTemplateSets, LlmModel } from '@/types';
|
||||||
|
|
||||||
|
export function useAnalysisConfig() {
|
||||||
|
const { data: initialAnalysisTemplateSets, mutate: mutateAnalysisTemplateSets } = useAnalysisTemplateSets();
|
||||||
|
const { data: llmProviders } = useLlmProviders();
|
||||||
|
|
||||||
|
const [localTemplateSets, setLocalTemplateSets] = useState<AnalysisTemplateSets>({});
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isSavingAnalysis, setIsSavingAnalysis] = useState(false);
|
||||||
|
const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
|
||||||
|
|
||||||
|
const [newTemplateId, setNewTemplateId] = useState('');
|
||||||
|
const [newTemplateName, setNewTemplateName] = useState('');
|
||||||
|
const [isCreatingTemplate, setIsCreatingTemplate] = useState(false);
|
||||||
|
|
||||||
|
const [isCreatingModule, setIsCreatingModule] = useState(false);
|
||||||
|
const [newModuleId, setNewModuleId] = useState('');
|
||||||
|
const [newModuleName, setNewModuleName] = useState('');
|
||||||
|
|
||||||
|
const allModels = useMemo(() => {
|
||||||
|
if (!llmProviders) return [];
|
||||||
|
const models: { providerId: string; providerName: string; model: LlmModel }[] = [];
|
||||||
|
Object.entries(llmProviders).forEach(([pId, provider]) => {
|
||||||
|
provider.models.forEach(m => {
|
||||||
|
if (m.is_active) {
|
||||||
|
models.push({
|
||||||
|
providerId: pId,
|
||||||
|
providerName: provider.name || pId,
|
||||||
|
model: m
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return models;
|
||||||
|
}, [llmProviders]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialAnalysisTemplateSets) return;
|
||||||
|
setLocalTemplateSets(initialAnalysisTemplateSets);
|
||||||
|
// 仅在未选择时,从后端数据中选择第一个模板;避免覆盖本地新增的选择与状态
|
||||||
|
setSelectedTemplateId(prev => prev ?? (Object.keys(initialAnalysisTemplateSets)[0] || null));
|
||||||
|
}, [initialAnalysisTemplateSets]);
|
||||||
|
|
||||||
|
const handleAnalysisChange = (moduleId: string, field: string, value: any) => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
setLocalTemplateSets(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedTemplateId]: {
|
||||||
|
...prev[selectedTemplateId],
|
||||||
|
modules: {
|
||||||
|
...prev[selectedTemplateId].modules,
|
||||||
|
[moduleId]: {
|
||||||
|
...prev[selectedTemplateId].modules[moduleId],
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAnalysis = async () => {
|
||||||
|
setIsSavingAnalysis(true);
|
||||||
|
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 = async () => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 新建后立即持久化
|
||||||
|
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 || !localTemplateSets[selectedTemplateId]) return;
|
||||||
|
if (!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 {
|
||||||
|
localTemplateSets,
|
||||||
|
selectedTemplateId,
|
||||||
|
setSelectedTemplateId,
|
||||||
|
allModels,
|
||||||
|
isSavingAnalysis,
|
||||||
|
analysisSaveMessage,
|
||||||
|
setAnalysisSaveMessage,
|
||||||
|
// Template creation
|
||||||
|
newTemplateId, setNewTemplateId,
|
||||||
|
newTemplateName, setNewTemplateName,
|
||||||
|
isCreatingTemplate, setIsCreatingTemplate,
|
||||||
|
handleAddTemplate,
|
||||||
|
handleDeleteTemplate,
|
||||||
|
// Module creation
|
||||||
|
isCreatingModule, setIsCreatingModule,
|
||||||
|
newModuleId, setNewModuleId,
|
||||||
|
newModuleName, setNewModuleName,
|
||||||
|
handleAddNewModule,
|
||||||
|
handleDeleteModule,
|
||||||
|
// Actions
|
||||||
|
handleAnalysisChange,
|
||||||
|
handleSaveAnalysis,
|
||||||
|
updateAnalysisDependencies
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
94
frontend/src/app/config/hooks/useDataSourcesConfigLogic.ts
Normal file
94
frontend/src/app/config/hooks/useDataSourcesConfigLogic.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useDataSourcesConfig as useDataSourcesConfigApi,
|
||||||
|
updateDataSourcesConfig,
|
||||||
|
testConfig
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import { DataSourcesConfig, DataSourceProvider, DataSourceConfig } from '@/types';
|
||||||
|
|
||||||
|
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 useDataSourcesConfigLogic() {
|
||||||
|
const { data: initialDataSources, error: dsError, isLoading: dsLoading, mutate: mutateDataSources } = useDataSourcesConfigApi();
|
||||||
|
const [localDataSources, setLocalDataSources] = useState<DataSourcesConfig>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; summary: string; details?: string } | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialDataSources) {
|
||||||
|
setLocalDataSources(initialDataSources);
|
||||||
|
}
|
||||||
|
}, [initialDataSources]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveMessage('保存中...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (initialDataSources) {
|
||||||
|
const finalDataSources = JSON.parse(JSON.stringify(localDataSources));
|
||||||
|
for (const key in finalDataSources) {
|
||||||
|
const providerKey = key as DataSourceProvider;
|
||||||
|
const source = finalDataSources[providerKey];
|
||||||
|
if (source && (source.api_url === null || source.api_url.trim() === '') && defaultUrls[providerKey]) {
|
||||||
|
source.api_url = defaultUrls[providerKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDataSourcesConfig(finalDataSources);
|
||||||
|
await mutateDataSources(finalDataSources, false);
|
||||||
|
setLocalDataSources(finalDataSources);
|
||||||
|
}
|
||||||
|
setSaveMessage('保存成功!');
|
||||||
|
} catch (e: any) {
|
||||||
|
setSaveMessage(`保存失败: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setTimeout(() => setSaveMessage(''), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (initialDataSources) setLocalDataSources(initialDataSources);
|
||||||
|
setTestResults({});
|
||||||
|
setSaveMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
localDataSources,
|
||||||
|
setLocalDataSources,
|
||||||
|
dsLoading,
|
||||||
|
dsError,
|
||||||
|
saving,
|
||||||
|
saveMessage,
|
||||||
|
testResults,
|
||||||
|
handleSave,
|
||||||
|
handleReset,
|
||||||
|
handleTest,
|
||||||
|
defaultUrls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
57
frontend/src/app/config/hooks/useSystemConfig.ts
Normal file
57
frontend/src/app/config/hooks/useSystemConfig.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
|
||||||
|
export function useSystemConfig() {
|
||||||
|
const { config } = useConfigStore();
|
||||||
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
|
|
||||||
|
const handleExportConfig = () => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const configToExport = {
|
||||||
|
new_api: config.new_api,
|
||||||
|
data_sources: config.data_sources,
|
||||||
|
export_time: new Date().toISOString(),
|
||||||
|
version: "1.0"
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(configToExport, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `config-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportConfig = (event: React.ChangeEvent<HTMLInputElement>, onImportSuccess?: (config: any) => void) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const importedConfig = JSON.parse(e.target?.result as string);
|
||||||
|
|
||||||
|
if (onImportSuccess) {
|
||||||
|
onImportSuccess(importedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveMessage('配置导入成功,请检查并保存');
|
||||||
|
} catch (error) {
|
||||||
|
setSaveMessage('配置文件格式错误,导入失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
saveMessage,
|
||||||
|
handleExportConfig,
|
||||||
|
handleImportConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
138
frontend/src/app/config/utils/yaml-helper.ts
Normal file
138
frontend/src/app/config/utils/yaml-helper.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user