@jiangxiaosheng/digital-agent-platform 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +37 -0
  2. package/eslint.config.mjs +20 -0
  3. package/messages/en.json +184 -0
  4. package/messages/zh.json +184 -0
  5. package/middleware.ts +9 -0
  6. package/next-env.d.ts +5 -0
  7. package/next.config.ts +16 -0
  8. package/package.json +59 -0
  9. package/postcss.config.mjs +5 -0
  10. package/src/app/[locale]/agents/[id]/chat/page.tsx +833 -0
  11. package/src/app/[locale]/agents/[id]/edit/page.tsx +58 -0
  12. package/src/app/[locale]/agents/new/page.tsx +52 -0
  13. package/src/app/[locale]/agents/page.tsx +122 -0
  14. package/src/app/[locale]/knowledge/page.tsx +305 -0
  15. package/src/app/[locale]/layout.tsx +46 -0
  16. package/src/app/[locale]/page.tsx +6 -0
  17. package/src/app/[locale]/settings/page.tsx +1204 -0
  18. package/src/app/api/agents/[id]/route.ts +27 -0
  19. package/src/app/api/agents/route.ts +27 -0
  20. package/src/app/api/auth-config/test/route.ts +69 -0
  21. package/src/app/api/bash-output/route.ts +57 -0
  22. package/src/app/api/chat/[agentId]/route.ts +164 -0
  23. package/src/app/api/env-vars/[key]/route.ts +22 -0
  24. package/src/app/api/env-vars/route.ts +26 -0
  25. package/src/app/api/env-vars/scan/route.ts +66 -0
  26. package/src/app/api/file-serve/route.ts +58 -0
  27. package/src/app/api/fs/browse/route.ts +47 -0
  28. package/src/app/api/knowledge/[id]/documents/[docId]/route.ts +22 -0
  29. package/src/app/api/knowledge/[id]/route.ts +21 -0
  30. package/src/app/api/knowledge/[id]/upload/route.ts +90 -0
  31. package/src/app/api/knowledge/route.ts +15 -0
  32. package/src/app/api/knowledge/search/route.ts +26 -0
  33. package/src/app/api/models/route.ts +160 -0
  34. package/src/app/api/models-config/route.ts +41 -0
  35. package/src/app/api/models-config/test/route.ts +84 -0
  36. package/src/app/api/sessions/[agentId]/[sessionId]/route.ts +12 -0
  37. package/src/app/api/sessions/[agentId]/route.ts +19 -0
  38. package/src/app/api/sessions/history/route.ts +166 -0
  39. package/src/app/api/settings/route.ts +45 -0
  40. package/src/app/api/skills/[name]/route.ts +40 -0
  41. package/src/app/api/skills/install/route.ts +35 -0
  42. package/src/app/api/skills/route.ts +54 -0
  43. package/src/app/api/skills/search/route.ts +60 -0
  44. package/src/app/api/tts/route.ts +235 -0
  45. package/src/app/api/voice-provider/route.ts +98 -0
  46. package/src/app/api/workspace-files/route.ts +151 -0
  47. package/src/app/globals.css +176 -0
  48. package/src/app/layout.tsx +13 -0
  49. package/src/app/page.tsx +6 -0
  50. package/src/components/agents/agent-form.tsx +457 -0
  51. package/src/components/agents/model-selector.tsx +290 -0
  52. package/src/components/agents/skills-manager.tsx +347 -0
  53. package/src/components/chat/input-bar.tsx +561 -0
  54. package/src/components/chat/message-bubble.tsx +395 -0
  55. package/src/components/layout/sidebar.tsx +304 -0
  56. package/src/components/providers.tsx +19 -0
  57. package/src/components/ui/badge.tsx +26 -0
  58. package/src/components/ui/button.tsx +43 -0
  59. package/src/components/ui/card.tsx +46 -0
  60. package/src/components/ui/dialog.tsx +74 -0
  61. package/src/components/ui/directory-picker.tsx +185 -0
  62. package/src/components/ui/input.tsx +18 -0
  63. package/src/components/ui/label.tsx +16 -0
  64. package/src/components/ui/scroll-area.tsx +41 -0
  65. package/src/components/ui/select.tsx +93 -0
  66. package/src/components/ui/slider.tsx +35 -0
  67. package/src/components/ui/switch.tsx +35 -0
  68. package/src/components/ui/tabs.tsx +48 -0
  69. package/src/components/ui/textarea.tsx +17 -0
  70. package/src/components/ui/toaster.tsx +50 -0
  71. package/src/hooks/use-toast.ts +43 -0
  72. package/src/i18n/request.ts +13 -0
  73. package/src/i18n/routing.ts +7 -0
  74. package/src/lib/agent-factory.ts +200 -0
  75. package/src/lib/agents/store.ts +95 -0
  76. package/src/lib/db/client.ts +25 -0
  77. package/src/lib/db/schema.ts +66 -0
  78. package/src/lib/env-vars.ts +33 -0
  79. package/src/lib/knowledge/chunker.ts +34 -0
  80. package/src/lib/knowledge/embedder.ts +48 -0
  81. package/src/lib/knowledge/retriever.ts +94 -0
  82. package/src/lib/knowledge/store.ts +74 -0
  83. package/src/lib/models-config.ts +55 -0
  84. package/src/lib/settings.ts +33 -0
  85. package/src/lib/skills.ts +79 -0
  86. package/src/lib/system-prompt.ts +52 -0
  87. package/src/lib/tool-builder.ts +19 -0
  88. package/src/lib/utils.ts +6 -0
  89. package/src/lib/voice-providers-config.ts +71 -0
  90. package/src/middleware.ts +9 -0
  91. package/src/types/agent.ts +124 -0
  92. package/src/types/knowledge.ts +39 -0
  93. package/src/types/settings.ts +31 -0
  94. package/tsconfig.json +40 -0
@@ -0,0 +1,1204 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useTheme } from "next-themes";
6
+ import { useLocale } from "next-intl";
7
+ import { useRouter, usePathname } from "next/navigation";
8
+ import {
9
+ Plus, Trash2, ChevronDown, ChevronRight,
10
+ XCircle, Loader2, RefreshCw, Save, Eye, EyeOff,
11
+ Check, Terminal,
12
+ } from "lucide-react";
13
+ import { Button } from "@/components/ui/button";
14
+ import { Input } from "@/components/ui/input";
15
+ import { Label } from "@/components/ui/label";
16
+ import { Switch } from "@/components/ui/switch";
17
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
18
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
19
+ import { Badge } from "@/components/ui/badge";
20
+ import { cn } from "@/lib/utils";
21
+ import type { ModelsConfig, ProviderEntry, ModelEntry } from "@/lib/models-config";
22
+ import type { VoiceProviderConfig } from "@/lib/voice-providers-config";
23
+ import { SkillsManager } from "@/components/agents/skills-manager";
24
+
25
+ // ─── Built-in provider definitions ────────────────────────────────────────
26
+ const BUILTIN_PROVIDERS = [
27
+ { id: "anthropic", name: "Anthropic", placeholder: "sk-ant-api03-..." },
28
+ { id: "openai", name: "OpenAI", placeholder: "sk-proj-..." },
29
+ { id: "google", name: "Google AI", placeholder: "AIzaSy..." },
30
+ { id: "groq", name: "Groq", placeholder: "gsk_..." },
31
+ { id: "deepseek", name: "DeepSeek", placeholder: "sk-..." },
32
+ { id: "xai", name: "xAI (Grok)", placeholder: "xai-..." },
33
+ { id: "mistral", name: "Mistral", placeholder: "..." },
34
+ ];
35
+
36
+ const API_TYPE_OPTIONS = [
37
+ { value: "anthropic-messages", label: "Anthropic Messages" },
38
+ { value: "openai-responses", label: "OpenAI Responses" },
39
+ { value: "openai-completions", label: "OpenAI Completions (Legacy)" },
40
+ { value: "google-generative-ai", label: "Google Generative AI" },
41
+ { value: "bedrock-converse-stream",label: "AWS Bedrock Converse" },
42
+ ];
43
+
44
+ // ─── Types ─────────────────────────────────────────────────────────────────
45
+ type TestState =
46
+ | { phase: "idle" }
47
+ | { phase: "testing" }
48
+ | { phase: "success"; latencyMs: number; responseText?: string }
49
+ | { phase: "error"; message: string; latencyMs?: number };
50
+
51
+ // ─── Main Settings Page ────────────────────────────────────────────────────
52
+ export default function SettingsPage() {
53
+ const t = useTranslations("settings");
54
+ const tc = useTranslations("common");
55
+ const { theme, setTheme } = useTheme();
56
+ const locale = useLocale();
57
+ const router = useRouter();
58
+ const pathname = usePathname();
59
+
60
+ // API keys (built-in providers)
61
+ const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
62
+ // Custom providers config
63
+ const [modelsConfig, setModelsConfig] = useState<ModelsConfig>({ providers: {} });
64
+ // Voice provider config
65
+ const [voiceConfig, setVoiceConfig] = useState<VoiceProviderConfig>({
66
+ currentProvider: "openai",
67
+ providers: {},
68
+ });
69
+ const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
70
+
71
+ // Load on mount
72
+ useEffect(() => {
73
+ fetch("/api/models-config")
74
+ .then((r) => r.json())
75
+ .then((d: { auth: Record<string, string>; modelsConfig: ModelsConfig }) => {
76
+ setApiKeys(d.auth ?? {});
77
+ setModelsConfig(d.modelsConfig ?? { providers: {} });
78
+ });
79
+
80
+ fetch("/api/voice-provider")
81
+ .then((r) => r.json())
82
+ .then((d: VoiceProviderConfig) => {
83
+ setVoiceConfig(d);
84
+ });
85
+ }, []);
86
+
87
+ const save = useCallback(async () => {
88
+ setSaveStatus("saving");
89
+ await fetch("/api/models-config", {
90
+ method: "PUT",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ auth: apiKeys, modelsConfig }),
93
+ });
94
+ await fetch("/api/voice-provider", {
95
+ method: "PUT",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify(voiceConfig),
98
+ });
99
+ setSaveStatus("saved");
100
+ setTimeout(() => setSaveStatus("idle"), 2500);
101
+ // Bust model cache so ModelSelector picks up changes
102
+ await fetch("/api/models");
103
+ }, [apiKeys, modelsConfig, voiceConfig]);
104
+
105
+ const switchLocale = (newLocale: string) => {
106
+ router.push(pathname.replace(/^\/(zh|en)/, `/${newLocale}`));
107
+ };
108
+
109
+ return (
110
+ <div className="p-6 max-w-3xl mx-auto">
111
+ <div className="mb-6 flex items-center justify-between p-4 rounded-xl bg-white dark:bg-slate-800/50 backdrop-blur-sm border border-gray-200 dark:border-slate-700/50 neo-card">
112
+ <div>
113
+ <h1 className="text-2xl font-bold text-gray-900 dark:bg-gradient-to-r dark:from-cyan-400 dark:to-blue-400 dark:bg-clip-text dark:text-transparent">{t("title")}</h1>
114
+ <p className="text-sm text-gray-700 dark:text-gray-400 mt-1">{t("subtitle")}</p>
115
+ </div>
116
+ <Button onClick={save} disabled={saveStatus === "saving"} className="neo-button gap-2 bg-gradient-to-r from-blue-500 to-indigo-500 dark:from-cyan-600 dark:to-blue-600 hover:from-blue-600 hover:to-indigo-600 dark:hover:from-cyan-500 dark:hover:to-blue-500 text-white border-0 shadow-lg hover:shadow-blue-500/30 dark:hover:shadow-cyan-500/50 transition-all duration-200 hover:scale-105">
117
+ {saveStatus === "saving" ? (
118
+ <Loader2 className="h-4 w-4 animate-spin" />
119
+ ) : saveStatus === "saved" ? (
120
+ <Check className="h-4 w-4" />
121
+ ) : (
122
+ <Save className="h-4 w-4" />
123
+ )}
124
+ {saveStatus === "saving" ? "保存中…" : saveStatus === "saved" ? "已保存" : tc("save")}
125
+ </Button>
126
+ </div>
127
+
128
+ <Tabs defaultValue="providers">
129
+ <TabsList className="mb-6 neo-card bg-gray-50 dark:bg-slate-800/50 backdrop-blur-sm border border-gray-200 dark:border-slate-700/50 p-1">
130
+ <TabsTrigger value="providers" className="data-[state=active]:neo-card-inset data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700 dark:data-[state=active]:bg-gradient-to-r dark:data-[state=active]:from-cyan-600 dark:data-[state=active]:to-blue-600 dark:data-[state=active]:text-white hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-all duration-200">内置提供商</TabsTrigger>
131
+ <TabsTrigger value="custom" className="data-[state=active]:neo-card-inset data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700 dark:data-[state=active]:bg-gradient-to-r dark:data-[state=active]:from-cyan-600 dark:data-[state=active]:to-blue-600 dark:data-[state=active]:text-white hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-all duration-200">自定义提供商</TabsTrigger>
132
+ <TabsTrigger value="voice" className="data-[state=active]:neo-card-inset data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700 dark:data-[state=active]:bg-gradient-to-r dark:data-[state=active]:from-cyan-600 dark:data-[state=active]:to-blue-600 dark:data-[state=active]:text-white hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-all duration-200">语音供应商</TabsTrigger>
133
+ <TabsTrigger value="envvars" className="data-[state=active]:neo-card-inset data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700 dark:data-[state=active]:bg-gradient-to-r dark:data-[state=active]:from-cyan-600 dark:data-[state=active]:to-blue-600 dark:data-[state=active]:text-white hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-all duration-200">环境变量</TabsTrigger>
134
+ <TabsTrigger value="skills" className="data-[state=active]:neo-card-inset data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700 dark:data-[state=active]:bg-gradient-to-r dark:data-[state=active]:from-cyan-600 dark:data-[state=active]:to-blue-600 dark:data-[state=active]:text-white hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-all duration-200">Skills 管理</TabsTrigger>
135
+ <TabsTrigger value="appearance" className="data-[state=active]:neo-card-inset data-[state=active]:bg-blue-100 data-[state=active]:text-blue-700 dark:data-[state=active]:bg-gradient-to-r dark:data-[state=active]:from-cyan-600 dark:data-[state=active]:to-blue-600 dark:data-[state=active]:text-white hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-all duration-200">外观 & 语言</TabsTrigger>
136
+ </TabsList>
137
+
138
+ {/* ── Built-in providers ─────────────────────── */}
139
+ <TabsContent value="providers" className="space-y-4">
140
+ <BuiltinProvidersPanel
141
+ apiKeys={apiKeys}
142
+ onKeysChange={setApiKeys}
143
+ onSave={save}
144
+ />
145
+ </TabsContent>
146
+
147
+ {/* ── Custom providers ───────────────────────── */}
148
+ <TabsContent value="custom" className="space-y-4">
149
+ <div className="flex items-center justify-between">
150
+ <div>
151
+ <p className="text-sm text-muted-foreground">
152
+ 添加兼容 OpenAI / Anthropic API 的自定义提供商。配置存储在{" "}
153
+ <code className="font-mono text-xs bg-secondary px-1 rounded">~/.pi/agent/models.json</code>。
154
+ </p>
155
+ </div>
156
+ <Button
157
+ variant="outline" size="sm"
158
+ className="neo-button hover:scale-105 transition-transform"
159
+ onClick={() => {
160
+ const name = `custom-provider-${Date.now()}`;
161
+ setModelsConfig((prev) => ({
162
+ providers: {
163
+ ...prev.providers,
164
+ [name]: { baseUrl: "", api: "openai-completions", apiKey: "", models: [] },
165
+ },
166
+ }));
167
+ }}
168
+ >
169
+ <Plus className="mr-2 h-4 w-4" />
170
+ 添加提供商
171
+ </Button>
172
+ </div>
173
+
174
+ {Object.keys(modelsConfig.providers).length === 0 ? (
175
+ <div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground text-sm">
176
+ 暂无自定义提供商。点击「添加提供商」创建一个。
177
+ </div>
178
+ ) : (
179
+ <div className="space-y-4">
180
+ {Object.entries(modelsConfig.providers).map(([name, provider]) => (
181
+ <CustomProviderCard
182
+ key={name}
183
+ name={name}
184
+ provider={provider}
185
+ onChange={(updated) =>
186
+ setModelsConfig((prev) => ({
187
+ providers: { ...prev.providers, [name]: updated },
188
+ }))
189
+ }
190
+ onRename={(newName) => {
191
+ if (newName === name || !newName) return;
192
+ setModelsConfig((prev) => {
193
+ const { [name]: old, ...rest } = prev.providers;
194
+ return { providers: { ...rest, [newName]: old } };
195
+ });
196
+ }}
197
+ onDelete={() =>
198
+ setModelsConfig((prev) => {
199
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
200
+ const { [name]: _, ...rest } = prev.providers;
201
+ return { providers: rest };
202
+ })
203
+ }
204
+ />
205
+ ))}
206
+ </div>
207
+ )}
208
+ </TabsContent>
209
+
210
+ {/* ── 语音供应商 ────────────────────────────────── */}
211
+ <TabsContent value="voice" className="space-y-4">
212
+ <div className="mb-4">
213
+ <p className="text-sm text-muted-foreground">
214
+ 选择一个语音供应商并配置API密钥,全局生效。配置存储在{" "}
215
+ <code className="font-mono text-xs bg-secondary px-1 rounded">~/.pi/agent/voice-provider.json</code>。
216
+ </p>
217
+ </div>
218
+
219
+ {/* 当前选择的供应商 */}
220
+ <div className="neo-card p-4 bg-white dark:bg-slate-800/50 rounded-xl">
221
+ <Label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">当前语音供应商</Label>
222
+ <Select value={voiceConfig.currentProvider} onValueChange={(v: any) => setVoiceConfig(prev => ({ ...prev, currentProvider: v }))}>
223
+ <SelectTrigger className="neo-card-inset">
224
+ <SelectValue />
225
+ </SelectTrigger>
226
+ <SelectContent>
227
+ <SelectItem value="openai">OpenAI TTS</SelectItem>
228
+ <SelectItem value="aliyun">阿里云语音合成</SelectItem>
229
+ <SelectItem value="tencent">腾讯云TTS</SelectItem>
230
+ <SelectItem value="baidu">百度语音合成</SelectItem>
231
+ </SelectContent>
232
+ </Select>
233
+ </div>
234
+
235
+ {/* API密钥配置 */}
236
+ {voiceConfig.currentProvider === "openai" && (
237
+ <div className="neo-card p-4 bg-white dark:bg-slate-800/50 rounded-xl space-y-3">
238
+ <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">OpenAI TTS 配置</h3>
239
+ <div className="space-y-2">
240
+ <Label className="text-xs">API Key</Label>
241
+ <Input
242
+ type="password"
243
+ placeholder="sk-..."
244
+ value={voiceConfig.providers.openai?.apiKey || ""}
245
+ onChange={(e) => setVoiceConfig(prev => ({
246
+ ...prev,
247
+ providers: { ...prev.providers, openai: { ...prev.providers.openai, apiKey: e.target.value } }
248
+ }))}
249
+ />
250
+ </div>
251
+ </div>
252
+ )}
253
+
254
+ {voiceConfig.currentProvider === "aliyun" && (
255
+ <div className="neo-card p-4 bg-white dark:bg-slate-800/50 rounded-xl space-y-3">
256
+ <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">阿里云配置</h3>
257
+ <div className="grid grid-cols-2 gap-3">
258
+ <div className="space-y-2">
259
+ <Label className="text-xs">AppKey</Label>
260
+ <Input placeholder="..." value={voiceConfig.providers.aliyun?.appKey || ""} onChange={(e) => setVoiceConfig(prev => ({ ...prev, providers: { ...prev.providers, aliyun: { ...prev.providers.aliyun!, appKey: e.target.value, accessKeyId: prev.providers.aliyun?.accessKeyId || "", accessKeySecret: prev.providers.aliyun?.accessKeySecret || "" } } }))} />
261
+ </div>
262
+ <div className="space-y-2">
263
+ <Label className="text-xs">Access Key ID</Label>
264
+ <Input type="password" placeholder="..." value={voiceConfig.providers.aliyun?.accessKeyId || ""} onChange={(e) => setVoiceConfig(prev => ({ ...prev, providers: { ...prev.providers, aliyun: { ...prev.providers.aliyun!, appKey: prev.providers.aliyun?.appKey || "", accessKeyId: e.target.value, accessKeySecret: prev.providers.aliyun?.accessKeySecret || "" } } }))} />
265
+ </div>
266
+ <div className="space-y-2 col-span-2">
267
+ <Label className="text-xs">Access Key Secret</Label>
268
+ <Input type="password" placeholder="..." value={voiceConfig.providers.aliyun?.accessKeySecret || ""} onChange={(e) => setVoiceConfig(prev => ({ ...prev, providers: { ...prev.providers, aliyun: { ...prev.providers.aliyun!, appKey: prev.providers.aliyun?.appKey || "", accessKeyId: prev.providers.aliyun?.accessKeyId || "", accessKeySecret: e.target.value } } }))} />
269
+ </div>
270
+ </div>
271
+ </div>
272
+ )}
273
+
274
+ {voiceConfig.currentProvider === "tencent" && (
275
+ <div className="neo-card p-4 bg-white dark:bg-slate-800/50 rounded-xl space-y-3">
276
+ <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">腾讯云配置</h3>
277
+ <div className="grid grid-cols-2 gap-3">
278
+ <div className="space-y-2">
279
+ <Label className="text-xs">Secret ID</Label>
280
+ <Input type="password" placeholder="AKID..." value={voiceConfig.providers.tencent?.secretId || ""} onChange={(e) => setVoiceConfig(prev => ({ ...prev, providers: { ...prev.providers, tencent: { ...prev.providers.tencent, secretId: e.target.value, secretKey: prev.providers.tencent?.secretKey || "" } } }))} />
281
+ </div>
282
+ <div className="space-y-2">
283
+ <Label className="text-xs">Secret Key</Label>
284
+ <Input type="password" placeholder="..." value={voiceConfig.providers.tencent?.secretKey || ""} onChange={(e) => setVoiceConfig(prev => ({ ...prev, providers: { ...prev.providers, tencent: { ...prev.providers.tencent, secretId: prev.providers.tencent?.secretId || "", secretKey: e.target.value } } }))} />
285
+ </div>
286
+ </div>
287
+ </div>
288
+ )}
289
+
290
+ {voiceConfig.currentProvider === "baidu" && (
291
+ <div className="neo-card p-4 bg-white dark:bg-slate-800/50 rounded-xl space-y-3">
292
+ <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">百度配置</h3>
293
+ <div className="grid grid-cols-2 gap-3">
294
+ <div className="space-y-2">
295
+ <Label className="text-xs">API Key</Label>
296
+ <Input type="password" placeholder="..." value={voiceConfig.providers.baidu?.apiKey || ""} onChange={(e) => setVoiceConfig(prev => ({ ...prev, providers: { ...prev.providers, baidu: { ...prev.providers.baidu, apiKey: e.target.value, secretKey: prev.providers.baidu?.secretKey || "" } } }))} />
297
+ </div>
298
+ <div className="space-y-2">
299
+ <Label className="text-xs">Secret Key</Label>
300
+ <Input type="password" placeholder="..." value={voiceConfig.providers.baidu?.secretKey || ""} onChange={(e) => setVoiceConfig(prev => ({ ...prev, providers: { ...prev.providers, baidu: { ...prev.providers.baidu, apiKey: prev.providers.baidu?.apiKey || "", secretKey: e.target.value } } }))} />
301
+ </div>
302
+ </div>
303
+ </div>
304
+ )}
305
+ </TabsContent>
306
+
307
+ {/* ── 环境变量 ────────────────────────────────── */}
308
+ <TabsContent value="envvars">
309
+ <EnvVarsPanel />
310
+ </TabsContent>
311
+
312
+ {/* ── Skills 管理 ─────────────────────────────── */}
313
+ <TabsContent value="skills" className="space-y-2">
314
+ <div className="mb-4">
315
+ <p className="text-sm text-muted-foreground">
316
+ 安装、卸载 Skills。Skills 存储在{" "}
317
+ <code className="font-mono text-xs bg-secondary px-1 rounded">~/.pi/agent/skills/</code>,
318
+ 对所有数字人共享。在数字人配置中可按需启用/禁用。
319
+ </p>
320
+ </div>
321
+ <SkillsManager />
322
+ </TabsContent>
323
+
324
+ {/* ── Appearance ─────────────────────────────── */}
325
+ <TabsContent value="appearance" className="space-y-6">
326
+ <div className="space-y-1.5">
327
+ <Label>{t("theme")}</Label>
328
+ <Select value={theme} onValueChange={setTheme}>
329
+ <SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
330
+ <SelectContent>
331
+ <SelectItem value="light">{t("themeLight")}</SelectItem>
332
+ <SelectItem value="dark">{t("themeDark")}</SelectItem>
333
+ <SelectItem value="system">{t("themeSystem")}</SelectItem>
334
+ </SelectContent>
335
+ </Select>
336
+ </div>
337
+
338
+ <div className="space-y-1.5">
339
+ <Label>{t("language")}</Label>
340
+ <Select value={locale} onValueChange={switchLocale}>
341
+ <SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
342
+ <SelectContent>
343
+ <SelectItem value="zh">中文</SelectItem>
344
+ <SelectItem value="en">English</SelectItem>
345
+ </SelectContent>
346
+ </Select>
347
+ </div>
348
+ </TabsContent>
349
+ </Tabs>
350
+ </div>
351
+ );
352
+ }
353
+
354
+ // ─── ApiKeyRow ─────────────────────────────────────────────────────────────
355
+ function ApiKeyRow({
356
+ providerId, providerName, placeholder, value, onChange,
357
+ }: { providerId: string; providerName: string; placeholder: string; value: string; onChange: (v: string) => void }) {
358
+ const [show, setShow] = useState(false);
359
+ const masked = value.startsWith("***");
360
+
361
+ return (
362
+ <div className="flex items-center gap-3">
363
+ <div className="w-32 shrink-0">
364
+ <Label className="text-sm">{providerName}</Label>
365
+ <p className="text-[11px] text-muted-foreground font-mono">{providerId}</p>
366
+ </div>
367
+ <div className="relative flex-1">
368
+ <Input
369
+ type={show || masked ? "text" : "password"}
370
+ value={masked ? value : value}
371
+ onChange={(e) => onChange(e.target.value)}
372
+ placeholder={masked ? "已配置 (输入新值覆盖)" : placeholder}
373
+ className={cn("pr-10 font-mono text-sm", masked && "text-muted-foreground")}
374
+ />
375
+ {!masked && (
376
+ <button
377
+ type="button"
378
+ onClick={() => setShow(!show)}
379
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
380
+ >
381
+ {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
382
+ </button>
383
+ )}
384
+ </div>
385
+ {value && (
386
+ <Badge variant={masked ? "secondary" : "outline"} className="text-xs shrink-0">
387
+ {masked ? "已配置" : "待保存"}
388
+ </Badge>
389
+ )}
390
+ </div>
391
+ );
392
+ }
393
+
394
+ // ─── CustomProviderCard ────────────────────────────────────────────────────
395
+ function CustomProviderCard({
396
+ name, provider, onChange, onRename, onDelete,
397
+ }: {
398
+ name: string;
399
+ provider: ProviderEntry;
400
+ onChange: (p: ProviderEntry) => void;
401
+ onRename: (newName: string) => void;
402
+ onDelete: () => void;
403
+ }) {
404
+ const [expanded, setExpanded] = useState(true);
405
+ const [editName, setEditName] = useState(name);
406
+ const [showKey, setShowKey] = useState(false);
407
+ const [testState, setTestState] = useState<TestState>({ phase: "idle" });
408
+
409
+ const set = (key: keyof ProviderEntry, val: unknown) => onChange({ ...provider, [key]: val });
410
+
411
+ const testModel = async (model: ModelEntry) => {
412
+ setTestState({ phase: "testing" });
413
+ try {
414
+ const res = await fetch("/api/models-config/test", {
415
+ method: "POST",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify({ providerName: name, provider, model }),
418
+ });
419
+ const d = await res.json();
420
+ if (d.ok) setTestState({ phase: "success", latencyMs: d.latencyMs, responseText: d.responseText });
421
+ else setTestState({ phase: "error", message: d.error, latencyMs: d.latencyMs });
422
+ } catch (e) {
423
+ setTestState({ phase: "error", message: String(e) });
424
+ }
425
+ };
426
+
427
+ const addModel = () => {
428
+ const models = [...(provider.models ?? []), {
429
+ id: "", name: "", contextWindow: 128000, maxTokens: 4096,
430
+ input: ["text", "image"], reasoning: false,
431
+ cost: { input: 0, output: 0 },
432
+ }];
433
+ set("models", models);
434
+ };
435
+
436
+ const updateModel = (i: number, key: keyof ModelEntry, val: unknown) => {
437
+ const models = [...(provider.models ?? [])];
438
+ models[i] = { ...models[i], [key]: val };
439
+ set("models", models);
440
+ };
441
+
442
+ const deleteModel = (i: number) => {
443
+ const models = [...(provider.models ?? [])];
444
+ models.splice(i, 1);
445
+ set("models", models);
446
+ };
447
+
448
+ return (
449
+ <div className="rounded-xl border bg-card overflow-hidden">
450
+ {/* Header */}
451
+ <div
452
+ className="flex items-center gap-2 px-4 py-3 cursor-pointer hover:bg-secondary/30 transition-colors"
453
+ onClick={() => setExpanded(!expanded)}
454
+ >
455
+ {expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
456
+ <span className="font-mono text-sm font-medium flex-1 truncate">{name}</span>
457
+ <Badge variant="outline" className="text-xs">{provider.api ?? "—"}</Badge>
458
+ <Badge variant="secondary" className="text-xs">{provider.models?.length ?? 0} models</Badge>
459
+ <button
460
+ type="button"
461
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
462
+ className="ml-1 text-muted-foreground hover:text-destructive transition-colors"
463
+ >
464
+ <Trash2 className="h-4 w-4" />
465
+ </button>
466
+ </div>
467
+
468
+ {expanded && (
469
+ <div className="px-4 pb-4 space-y-4 border-t">
470
+ {/* Provider name */}
471
+ <div className="pt-4 space-y-1.5">
472
+ <Label className="text-xs text-muted-foreground uppercase tracking-wider">提供商名称(唯一标识)</Label>
473
+ <div className="flex gap-2">
474
+ <Input
475
+ value={editName}
476
+ onChange={(e) => setEditName(e.target.value)}
477
+ className="font-mono text-sm"
478
+ placeholder="my-custom-provider"
479
+ />
480
+ {editName !== name && editName.trim() && (
481
+ <Button size="sm" onClick={() => onRename(editName.trim())}>重命名</Button>
482
+ )}
483
+ </div>
484
+ </div>
485
+
486
+ <div className="grid grid-cols-2 gap-4">
487
+ {/* Base URL */}
488
+ <div className="space-y-1.5">
489
+ <Label className="text-xs text-muted-foreground uppercase tracking-wider">Base URL</Label>
490
+ <Input
491
+ value={provider.baseUrl ?? ""}
492
+ onChange={(e) => set("baseUrl", e.target.value || undefined)}
493
+ placeholder="https://api.example.com/v1"
494
+ className="font-mono text-sm"
495
+ />
496
+ </div>
497
+
498
+ {/* API Type */}
499
+ <div className="space-y-1.5">
500
+ <Label className="text-xs text-muted-foreground uppercase tracking-wider">API 协议</Label>
501
+ <Select value={provider.api ?? "openai-completions"} onValueChange={(v) => set("api", v)}>
502
+ <SelectTrigger><SelectValue /></SelectTrigger>
503
+ <SelectContent>
504
+ {API_TYPE_OPTIONS.map((o) => (
505
+ <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
506
+ ))}
507
+ </SelectContent>
508
+ </Select>
509
+ </div>
510
+ </div>
511
+
512
+ {/* API Key */}
513
+ <div className="space-y-1.5">
514
+ <Label className="text-xs text-muted-foreground uppercase tracking-wider">API Key</Label>
515
+ <div className="relative">
516
+ <Input
517
+ type={showKey ? "text" : "password"}
518
+ value={provider.apiKey ?? ""}
519
+ onChange={(e) => set("apiKey", e.target.value || undefined)}
520
+ placeholder="ENV_VAR_NAME, !shell-command, 或直接填写密钥"
521
+ className="pr-10 font-mono text-sm"
522
+ />
523
+ <button
524
+ type="button"
525
+ onClick={() => setShowKey(!showKey)}
526
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
527
+ >
528
+ {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
529
+ </button>
530
+ </div>
531
+ <p className="text-[11px] text-muted-foreground">
532
+ 前缀 <code className="font-mono">!</code> 运行 Shell 命令,或填写环境变量名(自动读取)
533
+ </p>
534
+ </div>
535
+
536
+ {/* Models */}
537
+ <div className="space-y-2">
538
+ <div className="flex items-center justify-between">
539
+ <Label className="text-xs text-muted-foreground uppercase tracking-wider">模型列表</Label>
540
+ <Button variant="ghost" size="sm" onClick={addModel} className="h-7 text-xs">
541
+ <Plus className="mr-1 h-3 w-3" />添加模型
542
+ </Button>
543
+ </div>
544
+
545
+ {(provider.models ?? []).length === 0 ? (
546
+ <p className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded-lg">
547
+ 暂无模型,点击「添加模型」
548
+ </p>
549
+ ) : (
550
+ <div className="space-y-3">
551
+ {(provider.models ?? []).map((model, i) => (
552
+ <ModelRow
553
+ key={i}
554
+ model={model}
555
+ onUpdate={(k, v) => updateModel(i, k, v)}
556
+ onDelete={() => deleteModel(i)}
557
+ onTest={() => testModel(model)}
558
+ testState={testState}
559
+ />
560
+ ))}
561
+ </div>
562
+ )}
563
+ </div>
564
+
565
+ {/* Test result */}
566
+ {testState.phase !== "idle" && testState.phase !== "testing" && (
567
+ <div className={cn(
568
+ "flex items-start gap-2 rounded-lg px-3 py-2 text-sm",
569
+ testState.phase === "success"
570
+ ? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
571
+ : "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400",
572
+ )}>
573
+ {testState.phase === "success"
574
+ ? <Check className="h-4 w-4 mt-0.5 shrink-0" />
575
+ : <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
576
+ <div>
577
+ {testState.phase === "success"
578
+ ? `连接成功 (${testState.latencyMs}ms)${testState.responseText ? ` — "${testState.responseText}"` : ""}`
579
+ : `连接失败: ${testState.message}${testState.latencyMs ? ` (${testState.latencyMs}ms)` : ""}`}
580
+ </div>
581
+ <button onClick={() => setTestState({ phase: "idle" })} className="ml-auto text-current opacity-50 hover:opacity-100">
582
+ <XCircle className="h-3 w-3" />
583
+ </button>
584
+ </div>
585
+ )}
586
+ </div>
587
+ )}
588
+ </div>
589
+ );
590
+ }
591
+
592
+ // ─── ModelRow ──────────────────────────────────────────────────────────────
593
+ function ModelRow({
594
+ model, onUpdate, onDelete, onTest, testState,
595
+ }: {
596
+ model: ModelEntry;
597
+ onUpdate: (key: keyof ModelEntry, val: unknown) => void;
598
+ onDelete: () => void;
599
+ onTest: () => void;
600
+ testState: TestState;
601
+ }) {
602
+ const isTesting = testState.phase === "testing";
603
+
604
+ return (
605
+ <div className="rounded-lg border bg-secondary/20 p-3 space-y-3">
606
+ <div className="flex items-center gap-2">
607
+ <div className="flex-1 min-w-0 grid grid-cols-2 gap-2">
608
+ <Input
609
+ value={model.id}
610
+ onChange={(e) => onUpdate("id", e.target.value)}
611
+ placeholder="model-id(如 gpt-4o)"
612
+ className="font-mono text-xs h-8"
613
+ />
614
+ <Input
615
+ value={model.name ?? ""}
616
+ onChange={(e) => onUpdate("name", e.target.value || undefined)}
617
+ placeholder="显示名称(可选)"
618
+ className="text-xs h-8"
619
+ />
620
+ </div>
621
+ <Button
622
+ variant="ghost" size="sm"
623
+ onClick={onTest}
624
+ disabled={!model.id || isTesting}
625
+ className="h-8 text-xs shrink-0 gap-1"
626
+ title="测试连接"
627
+ >
628
+ {isTesting
629
+ ? <Loader2 className="h-3 w-3 animate-spin" />
630
+ : <RefreshCw className="h-3 w-3" />}
631
+ 测试
632
+ </Button>
633
+ <button
634
+ type="button"
635
+ onClick={onDelete}
636
+ className="text-muted-foreground hover:text-destructive transition-colors"
637
+ >
638
+ <Trash2 className="h-4 w-4" />
639
+ </button>
640
+ </div>
641
+
642
+ <div className="grid grid-cols-3 gap-2">
643
+ <div>
644
+ <Label className="text-[10px] text-muted-foreground">Context Window</Label>
645
+ <Input
646
+ type="number"
647
+ value={model.contextWindow ?? 128000}
648
+ onChange={(e) => onUpdate("contextWindow", Number(e.target.value))}
649
+ className="text-xs h-7 mt-0.5"
650
+ />
651
+ </div>
652
+ <div>
653
+ <Label className="text-[10px] text-muted-foreground">Max Output Tokens</Label>
654
+ <Input
655
+ type="number"
656
+ value={model.maxTokens ?? 4096}
657
+ onChange={(e) => onUpdate("maxTokens", Number(e.target.value))}
658
+ className="text-xs h-7 mt-0.5"
659
+ />
660
+ </div>
661
+ <div className="flex flex-col justify-end">
662
+ <div className="flex items-center gap-2 h-7">
663
+ <Switch
664
+ id={`reasoning-${model.id}`}
665
+ checked={model.reasoning ?? false}
666
+ onCheckedChange={(v) => onUpdate("reasoning", v)}
667
+ className="scale-75"
668
+ />
669
+ <Label htmlFor={`reasoning-${model.id}`} className="text-[11px] cursor-pointer">
670
+ 支持 Thinking
671
+ </Label>
672
+ </div>
673
+ </div>
674
+ </div>
675
+
676
+ <div className="grid grid-cols-2 gap-2">
677
+ <div>
678
+ <Label className="text-[10px] text-muted-foreground">输入价格 ($/1M tokens)</Label>
679
+ <Input
680
+ type="number" step="0.01"
681
+ value={model.cost?.input ?? 0}
682
+ onChange={(e) => onUpdate("cost", { ...model.cost, input: Number(e.target.value) })}
683
+ className="text-xs h-7 mt-0.5"
684
+ />
685
+ </div>
686
+ <div>
687
+ <Label className="text-[10px] text-muted-foreground">输出价格 ($/1M tokens)</Label>
688
+ <Input
689
+ type="number" step="0.01"
690
+ value={model.cost?.output ?? 0}
691
+ onChange={(e) => onUpdate("cost", { ...model.cost, output: Number(e.target.value) })}
692
+ className="text-xs h-7 mt-0.5"
693
+ />
694
+ </div>
695
+ </div>
696
+ </div>
697
+ );
698
+ }
699
+
700
+
701
+ // ─── Types for scan result ──────────────────────────────────────────────────
702
+ interface ScanVar { name: string; skills: string[]; stored: boolean }
703
+ interface ScanResult { bySkill: { skillName: string; vars: { name: string; stored: boolean }[] }[]; summary: ScanVar[] }
704
+
705
+ // ─── EnvVarsPanel ──────────────────────────────────────────────────────────
706
+ function EnvVarsPanel() {
707
+ const [vars, setVars] = useState<Record<string, string>>({});
708
+ const [loading, setLoading] = useState(true);
709
+ const [newKey, setNewKey] = useState("");
710
+ const [newVal, setNewVal] = useState("");
711
+ const [editKey, setEditKey] = useState<string | null>(null);
712
+ const [editVal, setEditVal] = useState("");
713
+ const [error, setError] = useState("");
714
+ const [saved, setSaved] = useState<string | null>(null);
715
+ const [scan, setScan] = useState<ScanResult | null>(null);
716
+ const [scanning, setScanning] = useState(false);
717
+ // Quick-fill state for missing skill vars
718
+ const [quickVals, setQuickVals] = useState<Record<string, string>>({});
719
+
720
+ const load = useCallback(() => {
721
+ setLoading(true);
722
+ fetch("/api/env-vars", { cache: "no-store" })
723
+ .then(r => r.json())
724
+ .then(d => { setVars(d); setLoading(false); })
725
+ .catch(() => setLoading(false));
726
+ }, []);
727
+
728
+ useEffect(() => { load(); }, [load]);
729
+
730
+ const add = async (key = newKey, val = newVal) => {
731
+ setError("");
732
+ if (!key.trim()) { setError("变量名不能为空"); return; }
733
+ const res = await fetch("/api/env-vars", {
734
+ method: "POST",
735
+ headers: { "Content-Type": "application/json" },
736
+ body: JSON.stringify({ key: key.trim(), value: val }),
737
+ });
738
+ const d = await res.json();
739
+ if (!res.ok) { setError(d.error ?? "保存失败"); return; }
740
+ if (key === newKey) { setNewKey(""); setNewVal(""); }
741
+ setSaved(key.trim());
742
+ setTimeout(() => setSaved(null), 2500);
743
+ load();
744
+ setScan(null); // refresh scan after add
745
+ };
746
+
747
+ const saveEdit = async (key: string) => {
748
+ await fetch(`/api/env-vars/${encodeURIComponent(key)}`, {
749
+ method: "PUT",
750
+ headers: { "Content-Type": "application/json" },
751
+ body: JSON.stringify({ value: editVal }),
752
+ });
753
+ setEditKey(null); setSaved(key);
754
+ setTimeout(() => setSaved(null), 2500);
755
+ load();
756
+ };
757
+
758
+ const remove = async (key: string) => {
759
+ await fetch(`/api/env-vars/${encodeURIComponent(key)}`, { method: "DELETE" });
760
+ load(); setScan(null);
761
+ };
762
+
763
+ const runScan = async () => {
764
+ setScanning(true);
765
+ const d: ScanResult = await fetch("/api/env-vars/scan").then(r => r.json());
766
+ setScan(d);
767
+ setQuickVals({});
768
+ setScanning(false);
769
+ };
770
+
771
+ const quickAdd = async (name: string) => {
772
+ await add(name, quickVals[name] ?? "");
773
+ setQuickVals(prev => { const n = { ...prev }; delete n[name]; return n; });
774
+ // Refresh scan
775
+ setTimeout(runScan, 300);
776
+ };
777
+
778
+ const entries = Object.entries(vars).sort(([a], [b]) => a.localeCompare(b));
779
+ const missingScanVars = scan?.summary.filter(v => !v.stored) ?? [];
780
+
781
+ return (
782
+ <div className="space-y-6">
783
+ {/* Header */}
784
+ <div className="flex items-start gap-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 p-4">
785
+ <Terminal className="h-5 w-5 text-zinc-500 shrink-0 mt-0.5" />
786
+ <div className="flex-1">
787
+ <p className="text-sm font-medium">全局环境变量</p>
788
+ <p className="text-xs text-muted-foreground mt-0.5">
789
+ 存于 <code className="font-mono bg-zinc-100 dark:bg-zinc-800 px-1 rounded">~/.digital-agents/env.json</code>,
790
+ Bash 工具执行时自动注入。
791
+ </p>
792
+ </div>
793
+ <Button size="sm" variant="outline" onClick={runScan} disabled={scanning} className="gap-1.5 shrink-0">
794
+ {scanning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
795
+ 扫描 Skill 变量
796
+ </Button>
797
+ </div>
798
+
799
+ {/* ── Skill scan results ─────────────────────────────────── */}
800
+ {scan && (
801
+ <div className="rounded-xl border overflow-hidden">
802
+ <div className="flex items-center justify-between px-4 py-3 border-b bg-secondary/30">
803
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
804
+ Skill 引用的环境变量
805
+ </p>
806
+ <button type="button" onClick={() => setScan(null)} className="text-muted-foreground hover:text-foreground text-xs">关闭</button>
807
+ </div>
808
+ <div className="p-3 space-y-2">
809
+ {scan.summary.length === 0 ? (
810
+ <p className="text-sm text-muted-foreground text-center py-4">未发现 Skill 引用的环境变量</p>
811
+ ) : scan.summary.map(v => (
812
+ <div key={v.name} className={cn(
813
+ "flex items-center gap-2 rounded-lg border px-3 py-2.5 transition-all",
814
+ v.stored
815
+ ? "border-[#57BE6B]/40 bg-[#57BE6B]/8 dark:bg-[#57BE6B]/10"
816
+ : "border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10",
817
+ )}>
818
+ {/* Status */}
819
+ {v.stored ? (
820
+ <Check className="h-4 w-4 text-[#57BE6B] shrink-0" />
821
+ ) : (
822
+ <span className="h-4 w-4 rounded-full border-2 border-amber-400 shrink-0 flex items-center justify-center">
823
+ <span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
824
+ </span>
825
+ )}
826
+
827
+ {/* Var name + skills */}
828
+ <div className="flex-1 min-w-0">
829
+ <code className="text-xs font-mono font-semibold">{v.name}</code>
830
+ <p className="text-[10px] text-muted-foreground mt-0.5">
831
+ 引用于:{v.skills.join("、")}
832
+ </p>
833
+ </div>
834
+
835
+ {/* Status badge or quick-fill */}
836
+ {v.stored ? (
837
+ <span className="text-[10px] text-[#57BE6B] shrink-0">已配置</span>
838
+ ) : (
839
+ <div className="flex items-center gap-1.5 shrink-0">
840
+ <Input
841
+ value={quickVals[v.name] ?? ""}
842
+ onChange={e => setQuickVals(prev => ({ ...prev, [v.name]: e.target.value }))}
843
+ onKeyDown={e => e.key === "Enter" && quickAdd(v.name)}
844
+ placeholder="输入值..."
845
+ className="h-7 w-36 text-xs font-mono"
846
+ />
847
+ <Button size="sm" className="h-7 px-2 text-xs gap-1" onClick={() => quickAdd(v.name)}>
848
+ <Plus className="h-3 w-3" />添加
849
+ </Button>
850
+ </div>
851
+ )}
852
+ </div>
853
+ ))}
854
+ </div>
855
+ {missingScanVars.length > 0 && (
856
+ <div className="px-4 py-2.5 border-t bg-amber-50/30 dark:bg-amber-900/10">
857
+ <p className="text-xs text-amber-600 dark:text-amber-400">
858
+ ⚠️ {missingScanVars.length} 个变量尚未配置 — 相关 Skill 执行时可能报错
859
+ </p>
860
+ </div>
861
+ )}
862
+ </div>
863
+ )}
864
+
865
+ {/* ── Add form ───────────────────────────────────────────── */}
866
+ <div className="rounded-xl border p-4 space-y-3">
867
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">手动添加变量</p>
868
+ <div className="flex gap-2 items-start">
869
+ <Input
870
+ value={newKey}
871
+ onChange={e => { setNewKey(e.target.value); setError(""); }}
872
+ onKeyDown={e => e.key === "Enter" && add()}
873
+ placeholder="VARIABLE_NAME"
874
+ className="font-mono text-sm flex-1"
875
+ />
876
+ <span className="text-muted-foreground pt-2 text-sm shrink-0">=</span>
877
+ <Input
878
+ value={newVal}
879
+ onChange={e => setNewVal(e.target.value)}
880
+ onKeyDown={e => e.key === "Enter" && add()}
881
+ placeholder="value"
882
+ className="font-mono text-sm flex-1"
883
+ />
884
+ <Button size="sm" onClick={() => add()} className="gap-1.5 shrink-0">
885
+ <Plus className="h-3.5 w-3.5" />添加
886
+ </Button>
887
+ </div>
888
+ {error && <p className="text-xs text-destructive">{error}</p>}
889
+ </div>
890
+
891
+ {/* ── Stored vars list ───────────────────────────────────── */}
892
+ <div className="space-y-2">
893
+ <div className="flex items-center justify-between">
894
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
895
+ 已配置 ({entries.length})
896
+ </p>
897
+ {entries.length > 0 && (
898
+ <button
899
+ type="button"
900
+ onClick={async () => {
901
+ if (confirm("确认清空所有环境变量?")) {
902
+ await fetch("/api/env-vars", { method: "DELETE" });
903
+ load(); setScan(null);
904
+ }
905
+ }}
906
+ className="text-xs text-destructive hover:underline"
907
+ >
908
+ 清空全部
909
+ </button>
910
+ )}
911
+ </div>
912
+
913
+ {loading ? (
914
+ <div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
915
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
916
+ </div>
917
+ ) : entries.length === 0 ? (
918
+ <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
919
+ 暂无环境变量。点击「扫描 Skill 变量」自动发现 Skill 所需的变量名,或手动添加。
920
+ </div>
921
+ ) : (
922
+ entries.map(([key, val]) => (
923
+ <div
924
+ key={key}
925
+ className={cn(
926
+ "group flex items-center gap-2 rounded-lg border px-3 py-2.5 transition-all",
927
+ saved === key
928
+ ? "border-[#57BE6B]/60 bg-[#57BE6B]/10"
929
+ : "border-border hover:border-zinc-300 dark:hover:border-zinc-600",
930
+ )}
931
+ >
932
+ <code className="text-xs font-mono font-semibold shrink-0 min-w-[140px] truncate">{key}</code>
933
+ <span className="text-muted-foreground text-xs shrink-0">=</span>
934
+ {editKey === key ? (
935
+ <>
936
+ <Input
937
+ value={editVal}
938
+ onChange={e => setEditVal(e.target.value)}
939
+ onKeyDown={e => { if (e.key === "Enter") saveEdit(key); if (e.key === "Escape") setEditKey(null); }}
940
+ className="h-7 text-xs font-mono flex-1"
941
+ autoFocus
942
+ />
943
+ <Button size="sm" variant="ghost" className="h-7 px-2 text-[#57BE6B]" onClick={() => saveEdit(key)}>
944
+ <Check className="h-3.5 w-3.5" />
945
+ </Button>
946
+ <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setEditKey(null)}>
947
+ <XCircle className="h-3.5 w-3.5" />
948
+ </Button>
949
+ </>
950
+ ) : (
951
+ <>
952
+ <span
953
+ className="text-xs font-mono text-muted-foreground flex-1 truncate cursor-pointer hover:text-foreground"
954
+ onClick={() => { setEditKey(key); setEditVal(val); }}
955
+ title={val || "(空)"}
956
+ >
957
+ {val || <em className="opacity-40 not-italic">(空)</em>}
958
+ </span>
959
+ {saved === key && <span className="text-[10px] text-[#57BE6B] shrink-0">已保存</span>}
960
+ <button type="button"
961
+ className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-all"
962
+ onClick={() => { setEditKey(key); setEditVal(val); }} title="编辑">
963
+ <RefreshCw className="h-3.5 w-3.5" />
964
+ </button>
965
+ <button type="button"
966
+ className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all"
967
+ onClick={() => remove(key)} title="删除">
968
+ <Trash2 className="h-3.5 w-3.5" />
969
+ </button>
970
+ </>
971
+ )}
972
+ </div>
973
+ ))
974
+ )}
975
+ </div>
976
+ </div>
977
+ );
978
+ }
979
+
980
+ // ─── BuiltinProvidersPanel ──────────────────────────────────────────────────
981
+ const PROVIDER_META: Record<string, { name: string; placeholder: string; color: string }> = {
982
+ anthropic: { name: "Anthropic", placeholder: "sk-ant-api03-...", color: "bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800" },
983
+ openai: { name: "OpenAI", placeholder: "sk-proj-...", color: "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800" },
984
+ google: { name: "Google AI", placeholder: "AIzaSy...", color: "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800" },
985
+ groq: { name: "Groq", placeholder: "gsk_...", color: "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800" },
986
+ deepseek: { name: "DeepSeek", placeholder: "sk-...", color: "bg-cyan-50 dark:bg-cyan-900/20 border-cyan-200 dark:border-cyan-800" },
987
+ xai: { name: "xAI (Grok)", placeholder: "xai-...", color: "bg-zinc-50 dark:bg-zinc-800/50 border-zinc-200 dark:border-zinc-700" },
988
+ mistral: { name: "Mistral", placeholder: "...", color: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800" },
989
+ };
990
+
991
+ type ProviderTestState = { phase: "idle" } | { phase: "testing" } | { phase: "ok"; ms: number } | { phase: "err"; msg: string };
992
+
993
+ interface BuiltinProvidersPanelProps {
994
+ apiKeys: Record<string, string>;
995
+ onKeysChange: (keys: Record<string, string>) => void;
996
+ onSave: () => Promise<void>;
997
+ }
998
+
999
+ function BuiltinProvidersPanel({ apiKeys, onKeysChange, onSave }: BuiltinProvidersPanelProps) {
1000
+ const [addProvider, setAddProvider] = useState<string>("");
1001
+ const [addKey, setAddKey] = useState("");
1002
+ const [testState, setTestState] = useState<ProviderTestState>({ phase: "idle" });
1003
+ const [showAdd, setShowAdd] = useState(false);
1004
+ const [editId, setEditId] = useState<string | null>(null);
1005
+ const [editKey, setEditKey] = useState("");
1006
+
1007
+ // Providers that have a key configured
1008
+ const configured = Object.entries(apiKeys).filter(([, v]) => v && v.trim() !== "");
1009
+
1010
+ // Available to add (not yet configured)
1011
+ const available = Object.entries(PROVIDER_META).filter(([id]) => !apiKeys[id]);
1012
+
1013
+ const testKey = async (provider: string, key: string) => {
1014
+ setTestState({ phase: "testing" });
1015
+ try {
1016
+ const res = await fetch("/api/auth-config/test", {
1017
+ method: "POST",
1018
+ headers: { "Content-Type": "application/json" },
1019
+ body: JSON.stringify({ provider, key }),
1020
+ });
1021
+ const d = await res.json();
1022
+ if (d.ok) setTestState({ phase: "ok", ms: d.latencyMs });
1023
+ else setTestState({ phase: "err", msg: d.error ?? `HTTP ${d.status}` });
1024
+ } catch (e) {
1025
+ setTestState({ phase: "err", msg: String(e) });
1026
+ }
1027
+ };
1028
+
1029
+ const handleAdd = async () => {
1030
+ if (!addProvider || !addKey.trim()) return;
1031
+ // Test first
1032
+ await testKey(addProvider, addKey.trim());
1033
+ };
1034
+
1035
+ const confirmAdd = async () => {
1036
+ onKeysChange({ ...apiKeys, [addProvider]: addKey.trim() });
1037
+ await onSave();
1038
+ setAddProvider(""); setAddKey(""); setTestState({ phase: "idle" }); setShowAdd(false);
1039
+ };
1040
+
1041
+ const removeProvider = async (id: string) => {
1042
+ const updated = { ...apiKeys };
1043
+ delete updated[id];
1044
+ onKeysChange(updated);
1045
+ await onSave();
1046
+ };
1047
+
1048
+ const saveEdit = async (id: string) => {
1049
+ onKeysChange({ ...apiKeys, [id]: editKey.trim() });
1050
+ await onSave();
1051
+ setEditId(null); setEditKey("");
1052
+ };
1053
+
1054
+ return (
1055
+ <div className="space-y-4">
1056
+ <div className="flex items-center justify-between">
1057
+ <div>
1058
+ <p className="text-sm font-medium">LLM 提供商</p>
1059
+ <p className="text-xs text-muted-foreground mt-0.5">
1060
+ 密钥存储于 <code className="font-mono bg-secondary px-1 rounded">~/.pi/agent/auth.json</code>
1061
+ </p>
1062
+ </div>
1063
+ {!showAdd && (
1064
+ <Button variant="outline" size="sm" onClick={() => setShowAdd(true)} className="gap-1.5" disabled={available.length === 0}>
1065
+ <Plus className="h-3.5 w-3.5" />添加提供商
1066
+ </Button>
1067
+ )}
1068
+ </div>
1069
+
1070
+ {/* ── Configured providers list ─── */}
1071
+ {configured.length === 0 && !showAdd && (
1072
+ <div className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
1073
+ 还没有配置任何 LLM 提供商。点击「添加提供商」开始配置。
1074
+ </div>
1075
+ )}
1076
+
1077
+ <div className="space-y-2">
1078
+ {configured.map(([id, val]) => {
1079
+ const meta = PROVIDER_META[id];
1080
+ const isEditing = editId === id;
1081
+ return (
1082
+ <div key={id} className={cn("group flex items-center gap-3 rounded-xl border p-3.5 transition-all", meta?.color ?? "")}>
1083
+ <div className="w-28 shrink-0">
1084
+ <p className="text-sm font-medium">{meta?.name ?? id}</p>
1085
+ <p className="text-[11px] text-muted-foreground font-mono">{id}</p>
1086
+ </div>
1087
+ {isEditing ? (
1088
+ <>
1089
+ <Input
1090
+ type="password"
1091
+ value={editKey}
1092
+ onChange={e => setEditKey(e.target.value)}
1093
+ onKeyDown={e => { if (e.key === "Enter") saveEdit(id); if (e.key === "Escape") setEditId(null); }}
1094
+ placeholder={meta?.placeholder ?? "API Key"}
1095
+ className="flex-1 font-mono text-xs h-8"
1096
+ autoFocus
1097
+ />
1098
+ <Button size="sm" className="h-7 px-2 gap-1 text-xs" onClick={() => saveEdit(id)}>
1099
+ <Check className="h-3 w-3" />保存
1100
+ </Button>
1101
+ <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setEditId(null)}>
1102
+ <XCircle className="h-3.5 w-3.5" />
1103
+ </Button>
1104
+ </>
1105
+ ) : (
1106
+ <>
1107
+ <span className="flex-1 font-mono text-xs text-muted-foreground">
1108
+ {val.startsWith("***") ? val : "••••••••" + val.slice(-4)}
1109
+ </span>
1110
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
1111
+ <Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
1112
+ onClick={() => { setEditId(id); setEditKey(val.startsWith("***") ? "" : val); }}>
1113
+ 编辑
1114
+ </Button>
1115
+ <Button size="sm" variant="ghost" className="h-7 px-2 text-destructive hover:text-destructive"
1116
+ onClick={() => removeProvider(id)}>
1117
+ <Trash2 className="h-3.5 w-3.5" />
1118
+ </Button>
1119
+ </div>
1120
+ </>
1121
+ )}
1122
+ </div>
1123
+ );
1124
+ })}
1125
+ </div>
1126
+
1127
+ {/* ── Add provider form ─── */}
1128
+ {showAdd && (
1129
+ <div className="rounded-xl border-2 border-dashed border-[#57BE6B]/50 p-4 space-y-4">
1130
+ <div className="flex items-center justify-between">
1131
+ <p className="text-sm font-semibold">添加提供商</p>
1132
+ <button type="button" onClick={() => { setShowAdd(false); setTestState({ phase: "idle" }); setAddKey(""); setAddProvider(""); }}
1133
+ className="text-muted-foreground hover:text-foreground">
1134
+ <XCircle className="h-4 w-4" />
1135
+ </button>
1136
+ </div>
1137
+
1138
+ <div className="grid grid-cols-2 gap-3">
1139
+ <div className="space-y-1">
1140
+ <Label className="text-xs text-muted-foreground">提供商</Label>
1141
+ <select
1142
+ value={addProvider}
1143
+ onChange={e => { setAddProvider(e.target.value); setTestState({ phase: "idle" }); }}
1144
+ className="w-full h-9 rounded-md border border-input bg-white dark:bg-zinc-800 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
1145
+ >
1146
+ <option value="">选择提供商…</option>
1147
+ {available.map(([id, meta]) => (
1148
+ <option key={id} value={id}>{meta.name}</option>
1149
+ ))}
1150
+ </select>
1151
+ </div>
1152
+ <div className="space-y-1">
1153
+ <Label className="text-xs text-muted-foreground">API Key</Label>
1154
+ <Input
1155
+ type="password"
1156
+ value={addKey}
1157
+ onChange={e => { setAddKey(e.target.value); setTestState({ phase: "idle" }); }}
1158
+ onKeyDown={e => e.key === "Enter" && handleAdd()}
1159
+ placeholder={addProvider ? (PROVIDER_META[addProvider]?.placeholder ?? "API Key") : "先选择提供商"}
1160
+ className="font-mono text-sm"
1161
+ disabled={!addProvider}
1162
+ />
1163
+ </div>
1164
+ </div>
1165
+
1166
+ {/* Test result */}
1167
+ {testState.phase !== "idle" && (
1168
+ <div className={cn("flex items-center gap-2 rounded-lg px-3 py-2 text-sm",
1169
+ testState.phase === "testing" ? "bg-secondary" :
1170
+ testState.phase === "ok" ? "bg-[#57BE6B]/10 text-[#57BE6B]" :
1171
+ "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400"
1172
+ )}>
1173
+ {testState.phase === "testing" && <Loader2 className="h-4 w-4 animate-spin" />}
1174
+ {testState.phase === "ok" && <Check className="h-4 w-4" />}
1175
+ {testState.phase === "err" && <XCircle className="h-4 w-4" />}
1176
+ <span>
1177
+ {testState.phase === "testing" && "测试连通性中…"}
1178
+ {testState.phase === "ok" && `连接成功 (${testState.ms}ms) — 点击「确认添加」保存`}
1179
+ {testState.phase === "err" && `连接失败: ${testState.msg}`}
1180
+ </span>
1181
+ </div>
1182
+ )}
1183
+
1184
+ <div className="flex gap-2">
1185
+ <Button
1186
+ variant="outline"
1187
+ className="flex-1 gap-1.5"
1188
+ disabled={!addProvider || !addKey.trim() || testState.phase === "testing"}
1189
+ onClick={handleAdd}
1190
+ >
1191
+ {testState.phase === "testing" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
1192
+ 测试连通性
1193
+ </Button>
1194
+ {testState.phase === "ok" && (
1195
+ <Button className="flex-1 gap-1.5 bg-[#57BE6B] hover:bg-[#4aaa5d]" onClick={confirmAdd}>
1196
+ <Check className="h-3.5 w-3.5" />确认添加
1197
+ </Button>
1198
+ )}
1199
+ </div>
1200
+ </div>
1201
+ )}
1202
+ </div>
1203
+ );
1204
+ }