@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,176 @@
1
+ @import "tailwindcss";
2
+
3
+ @custom-variant dark (&:is(.dark *));
4
+
5
+ :root {
6
+ --background: 210 15% 96%;
7
+ --foreground: 220 10% 12%;
8
+ --card: 210 15% 98%;
9
+ --card-foreground: 220 10% 12%;
10
+ --popover: 210 15% 98%;
11
+ --popover-foreground: 220 10% 12%;
12
+ --primary: 210 70% 50%;
13
+ --primary-foreground: 0 0% 98%;
14
+ --secondary: 210 15% 92%;
15
+ --secondary-foreground: 220 10% 12%;
16
+ --muted: 210 15% 92%;
17
+ --muted-foreground: 220 8% 30%;
18
+ --accent: 210 70% 55%;
19
+ --accent-foreground: 0 0% 98%;
20
+ --destructive: 0 72% 50%;
21
+ --destructive-foreground: 0 0% 98%;
22
+ --border: 210 15% 88%;
23
+ --input: 210 15% 88%;
24
+ --ring: 210 70% 50%;
25
+ --radius: 1rem;
26
+ --sidebar-width: 280px;
27
+
28
+ /* 新拟态配色 */
29
+ --neo-bg: 210 15% 96%;
30
+ --neo-shadow-dark: 210 15% 80%;
31
+ --neo-shadow-light: 0 0% 100%;
32
+ }
33
+
34
+ .dark {
35
+ --background: 240 10% 8%;
36
+ --foreground: 0 0% 98%;
37
+ --card: 240 10% 12%;
38
+ --card-foreground: 0 0% 98%;
39
+ --popover: 240 10% 12%;
40
+ --popover-foreground: 0 0% 98%;
41
+ --primary: 271 81% 56%;
42
+ --primary-foreground: 0 0% 98%;
43
+ --secondary: 240 3.7% 15.9%;
44
+ --secondary-foreground: 0 0% 98%;
45
+ --muted: 240 3.7% 15.9%;
46
+ --muted-foreground: 240 5% 64.9%;
47
+ --accent: 189 100% 50%;
48
+ --accent-foreground: 0 0% 98%;
49
+ --destructive: 0 62.8% 30.6%;
50
+ --destructive-foreground: 0 0% 98%;
51
+ --border: 240 3.7% 20%;
52
+ --input: 240 3.7% 20%;
53
+ --ring: 189 100% 50%;
54
+ }
55
+
56
+ * {
57
+ border-color: hsl(var(--border));
58
+ }
59
+
60
+ body {
61
+ background-color: hsl(var(--background));
62
+ color: hsl(var(--foreground));
63
+ }
64
+
65
+ /* 网格背景 - 浅色模式无网格 */
66
+ body::before {
67
+ content: "";
68
+ position: fixed;
69
+ inset: 0;
70
+ pointer-events: none;
71
+ z-index: -1;
72
+ }
73
+
74
+ .dark body::before {
75
+ background-image:
76
+ linear-gradient(rgba(139, 92, 246, 0.1) 1px, transparent 1px),
77
+ linear-gradient(90deg, rgba(139, 92, 246, 0.1) 1px, transparent 1px);
78
+ background-size: 50px 50px;
79
+ }
80
+
81
+ /* 玻璃态效果 - 仅深色模式 */
82
+ .dark .glass-effect {
83
+ background: rgba(255, 255, 255, 0.05);
84
+ backdrop-filter: blur(20px);
85
+ border: 1px solid rgba(255, 255, 255, 0.1);
86
+ }
87
+
88
+ /* 霓虹发光效果 - 仅深色模式 */
89
+ .dark .neon-glow {
90
+ box-shadow: 0 0 20px rgba(0, 217, 255, 0.3);
91
+ }
92
+
93
+ .dark .neon-glow-purple {
94
+ box-shadow: 0 0 20px rgba(168, 85, 247, 0.4);
95
+ }
96
+
97
+ .dark .neon-glow-green {
98
+ box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
99
+ }
100
+
101
+ /* 新拟态效果 - 仅浅色模式 - 柔和版本 */
102
+ .neo-card {
103
+ background: hsl(var(--neo-bg));
104
+ box-shadow: 6px 6px 12px hsl(var(--neo-shadow-dark) / 0.3),
105
+ -6px -6px 12px hsl(var(--neo-shadow-light) / 0.8);
106
+ border: none;
107
+ }
108
+
109
+ .neo-card-inset {
110
+ background: hsl(var(--neo-bg));
111
+ box-shadow: inset 4px 4px 8px hsl(var(--neo-shadow-dark) / 0.25),
112
+ inset -4px -4px 8px hsl(var(--neo-shadow-light) / 0.8);
113
+ border: none;
114
+ }
115
+
116
+ .neo-button {
117
+ background: hsl(var(--neo-bg));
118
+ box-shadow: 4px 4px 8px hsl(var(--neo-shadow-dark) / 0.25),
119
+ -4px -4px 8px hsl(var(--neo-shadow-light) / 0.8);
120
+ border: none;
121
+ transition: all 0.3s ease;
122
+ }
123
+
124
+ .neo-button:hover {
125
+ box-shadow: 3px 3px 6px hsl(var(--neo-shadow-dark)),
126
+ -3px -3px 6px hsl(var(--neo-shadow-light));
127
+ }
128
+
129
+ .neo-button:active {
130
+ box-shadow: inset 3px 3px 6px hsl(var(--neo-shadow-dark)),
131
+ inset -3px -3px 6px hsl(var(--neo-shadow-light));
132
+ }
133
+
134
+ /* 深色模式替换新拟态为科技风格 */
135
+ .dark .neo-card,
136
+ .dark .neo-card-inset,
137
+ .dark .neo-button {
138
+ background: transparent;
139
+ box-shadow: none;
140
+ }
141
+
142
+ /* 浅色模式全局组件新拟态样式 */
143
+ [data-theme="light"] [class*="card"],
144
+ [class*="card"]:not(.dark *) {
145
+ box-shadow: 8px 8px 16px hsl(var(--neo-shadow-dark) / 0.4),
146
+ -8px -8px 16px hsl(var(--neo-shadow-light));
147
+ }
148
+
149
+ /* 深色模式移除组件新拟态效果 */
150
+ .dark [class*="card"] {
151
+ box-shadow: none;
152
+ }
153
+
154
+ /* 输入框新拟态样式(浅色模式) - 排除textarea */
155
+ input[type="text"],
156
+ input[type="email"],
157
+ input[type="password"],
158
+ input[type="number"],
159
+ input[type="search"],
160
+ input[type="url"],
161
+ select {
162
+ background: hsl(var(--neo-bg));
163
+ box-shadow: inset 2px 2px 4px hsl(var(--neo-shadow-dark) / 0.2),
164
+ inset -2px -2px 4px hsl(var(--neo-shadow-light) / 0.6);
165
+ }
166
+
167
+ .dark input[type="text"],
168
+ .dark input[type="email"],
169
+ .dark input[type="password"],
170
+ .dark input[type="number"],
171
+ .dark input[type="search"],
172
+ .dark input[type="url"],
173
+ .dark select {
174
+ background: transparent;
175
+ box-shadow: none;
176
+ }
@@ -0,0 +1,13 @@
1
+ import type { Metadata } from "next";
2
+ import "@/app/globals.css";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "Digital Agent Platform",
6
+ description: "Configure and chat with your AI digital agents",
7
+ };
8
+
9
+ // Minimal root layout required by Next.js App Router.
10
+ // Locale-specific layout (with Providers, Sidebar, etc.) lives in [locale]/layout.tsx.
11
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
12
+ return children;
13
+ }
@@ -0,0 +1,6 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ // Root `/` → `/zh` (default locale)
4
+ export default function RootPage() {
5
+ redirect("/zh");
6
+ }
@@ -0,0 +1,457 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import Link from "next/link";
5
+ import { useTranslations } from "next-intl";
6
+ import {
7
+ Terminal, FileText, FilePen, Search, FolderSearch,
8
+ Globe, Calculator, Mic, Volume2, Zap, Check,
9
+ } from "lucide-react";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Input } from "@/components/ui/input";
12
+ import { Textarea } from "@/components/ui/textarea";
13
+ import { Label } from "@/components/ui/label";
14
+ import { Switch } from "@/components/ui/switch";
15
+ import { Slider } from "@/components/ui/slider";
16
+ import { Badge } from "@/components/ui/badge";
17
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
18
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
19
+ import { ModelSelector, useModelCaps } from "@/components/agents/model-selector";
20
+ import { SkillsManager } from "@/components/agents/skills-manager";
21
+ import { DirectoryPicker } from "@/components/ui/directory-picker";
22
+ import { AVATAR_OPTIONS } from "@/types/agent";
23
+ import type { CreateAgentInput, DigitalAgent } from "@/types/agent";
24
+ import type { KnowledgeBase } from "@/types/knowledge";
25
+ import { cn } from "@/lib/utils";
26
+ import type { ModelCaps } from "@/components/agents/model-selector";
27
+
28
+ const THINKING_LABELS: Record<string, string> = {
29
+ off: "关闭", minimal: "最低", low: "低",
30
+ medium: "中等", high: "高", xhigh: "极高", max: "最大",
31
+ };
32
+
33
+ // ─── Tool card definitions ────────────────────────────────────────────────────
34
+ type ToolKey = keyof CreateAgentInput["tools"];
35
+ const TOOL_CARDS: {
36
+ key: ToolKey;
37
+ icon: React.ElementType;
38
+ color: string;
39
+ labelKey: string;
40
+ hintKey: string;
41
+ }[] = [
42
+ { key: "bash", icon: Terminal, color: "bg-orange-100 text-orange-600 dark:bg-orange-900/40 dark:text-orange-400", labelKey: "bash", hintKey: "bashHint" },
43
+ { key: "fileRead", icon: FileText, color: "bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-400", labelKey: "fileRead", hintKey: "fileReadHint" },
44
+ { key: "fileWrite", icon: FilePen, color: "bg-violet-100 text-violet-600 dark:bg-violet-900/40 dark:text-violet-400", labelKey: "fileWrite", hintKey: "fileWriteHint" },
45
+ { key: "grep", icon: Search, color: "bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-400", labelKey: "grep", hintKey: "grepHint" },
46
+ { key: "find", icon: FolderSearch, color: "bg-teal-100 text-teal-600 dark:bg-teal-900/40 dark:text-teal-400", labelKey: "find", hintKey: "findHint" },
47
+ { key: "calculator",icon: Calculator, color: "bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40 dark:text-yellow-400", labelKey: "calculator",hintKey: "calculatorHint"},
48
+ ];
49
+
50
+ interface AgentFormProps {
51
+ initialValues: CreateAgentInput | DigitalAgent;
52
+ onSave: (data: CreateAgentInput) => Promise<void>;
53
+ saving?: boolean;
54
+ submitLabel?: string;
55
+ }
56
+
57
+ export function AgentForm({ initialValues, onSave, saving, submitLabel }: AgentFormProps) {
58
+ const t = useTranslations("agents.form");
59
+ const tc = useTranslations("common");
60
+
61
+ const [form, setForm] = useState<CreateAgentInput>({ ...initialValues });
62
+ const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
63
+
64
+ const resolvedCaps = useModelCaps(form.model.provider, form.model.modelId);
65
+ const [modelCaps, setModelCaps] = useState<ModelCaps>(resolvedCaps);
66
+
67
+ useEffect(() => {
68
+ setModelCaps(resolvedCaps);
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [resolvedCaps.supportsThinking, resolvedCaps.supportsTemperature, resolvedCaps.contextWindow, resolvedCaps.maxTokens]);
71
+
72
+ useEffect(() => {
73
+ fetch("/api/knowledge").then((r) => r.json()).then(setKbs).catch(() => {});
74
+ }, []);
75
+
76
+ // ── helpers ──────────────────────────────────────────────────────────────
77
+ const update = <K extends keyof CreateAgentInput>(key: K, val: CreateAgentInput[K]) =>
78
+ setForm((p) => ({ ...p, [key]: val }));
79
+ const updateModel = (key: keyof CreateAgentInput["model"], val: unknown) =>
80
+ setForm((p) => ({ ...p, model: { ...p.model, [key]: val } }));
81
+ const updatePersona = (key: keyof CreateAgentInput["persona"], val: string) =>
82
+ setForm((p) => ({ ...p, persona: { ...p.persona, [key]: val } }));
83
+ const toggleTool = (key: ToolKey) =>
84
+ setForm((p) => ({ ...p, tools: { ...p.tools, [key]: !p.tools[key] } }));
85
+ const toggleKb = (id: string) =>
86
+ setForm((p) => ({
87
+ ...p,
88
+ knowledgeBases: p.knowledgeBases.includes(id)
89
+ ? p.knowledgeBases.filter((x) => x !== id)
90
+ : [...p.knowledgeBases, id],
91
+ }));
92
+
93
+ const handleModelChange = (pid: string, mid: string, caps: ModelCaps) => {
94
+ setModelCaps(caps);
95
+ setForm((p) => ({
96
+ ...p,
97
+ model: {
98
+ ...p.model,
99
+ provider: pid,
100
+ modelId: mid,
101
+ thinkingLevel: (caps.supportsThinking
102
+ ? (p.model.thinkingLevel !== "off" ? p.model.thinkingLevel : (caps.thinkingLevels[0] ?? "medium"))
103
+ : "off") as CreateAgentInput["model"]["thinkingLevel"],
104
+ },
105
+ }));
106
+ };
107
+
108
+ const thinkingOptions =
109
+ modelCaps.supportsThinking && modelCaps.thinkingLevels.length > 0
110
+ ? modelCaps.thinkingLevels
111
+ : modelCaps.supportsThinking
112
+ ? ["low", "medium", "high", "xhigh", "max"]
113
+ : [];
114
+
115
+ const safeThinkingLevel =
116
+ thinkingOptions.includes(form.model.thinkingLevel)
117
+ ? form.model.thinkingLevel
118
+ : thinkingOptions[0] ?? "off";
119
+
120
+ const enabledToolCount = Object.values(form.tools).filter(Boolean).length;
121
+ const enabledSkillCount = (form.enabledSkills ?? []).length;
122
+
123
+ return (
124
+ <form onSubmit={(e) => { e.preventDefault(); onSave(form); }}>
125
+ <Tabs defaultValue="basic">
126
+ <TabsList className="mb-6 w-full grid grid-cols-5 h-10">
127
+ <TabsTrigger value="basic" className="text-xs">基本信息</TabsTrigger>
128
+ <TabsTrigger value="model" className="text-xs">{t("modelConfig")}</TabsTrigger>
129
+ <TabsTrigger value="tools" className="text-xs relative">
130
+ {t("toolsConfig")}
131
+ {enabledToolCount > 0 && (
132
+ <span className="ml-1 inline-flex items-center justify-center h-4 w-4 rounded-full bg-primary text-primary-foreground text-[9px] font-bold">
133
+ {enabledToolCount}
134
+ </span>
135
+ )}
136
+ </TabsTrigger>
137
+ <TabsTrigger value="skills" className="text-xs relative">
138
+ Skills
139
+ {enabledSkillCount > 0 && (
140
+ <span className="ml-1 inline-flex items-center justify-center h-4 w-4 rounded-full bg-purple-500 text-white text-[9px] font-bold">
141
+ {enabledSkillCount}
142
+ </span>
143
+ )}
144
+ </TabsTrigger>
145
+ </TabsList>
146
+
147
+ {/* ── 基本信息 ──────────────────────────────────── */}
148
+ <TabsContent value="basic" className="space-y-5">
149
+ <div className="space-y-2">
150
+ <Label>{t("avatar")}</Label>
151
+ <div className="flex flex-wrap gap-2">
152
+ {AVATAR_OPTIONS.map((emoji) => (
153
+ <button key={emoji} type="button" onClick={() => update("avatar", emoji)}
154
+ className={cn("h-10 w-10 rounded-xl text-xl transition-all hover:scale-110",
155
+ form.avatar === emoji && "ring-2 ring-primary ring-offset-2 bg-secondary")}>
156
+ {emoji}
157
+ </button>
158
+ ))}
159
+ </div>
160
+ </div>
161
+ <div className="grid grid-cols-2 gap-4">
162
+ <div className="space-y-1.5">
163
+ <Label htmlFor="name">{t("name")} *</Label>
164
+ <Input id="name" required value={form.name}
165
+ onChange={(e) => update("name", e.target.value)}
166
+ placeholder={t("namePlaceholder")} />
167
+ </div>
168
+ <div className="space-y-1.5">
169
+ <Label htmlFor="desc">{t("description")}</Label>
170
+ <Input id="desc" value={form.description}
171
+ onChange={(e) => update("description", e.target.value)}
172
+ placeholder={t("descriptionPlaceholder")} />
173
+ </div>
174
+ </div>
175
+ <div className="space-y-1.5">
176
+ <Label htmlFor="prompt">{t("systemPrompt")}</Label>
177
+ <Textarea id="prompt" rows={8} value={form.systemPrompt}
178
+ onChange={(e) => update("systemPrompt", e.target.value)}
179
+ placeholder={t("systemPromptPlaceholder")} />
180
+ </div>
181
+ <div className="grid grid-cols-2 gap-4">
182
+ <div className="space-y-1.5">
183
+ <Label>{t("tone")}</Label>
184
+ <Select value={form.persona.tone} onValueChange={(v) => updatePersona("tone", v)}>
185
+ <SelectTrigger><SelectValue /></SelectTrigger>
186
+ <SelectContent>
187
+ {(["formal","casual","friendly","professional"] as const).map((tone) => (
188
+ <SelectItem key={tone} value={tone}>{t(`tones.${tone}`)}</SelectItem>
189
+ ))}
190
+ </SelectContent>
191
+ </Select>
192
+ </div>
193
+ <div className="space-y-1.5">
194
+ <Label>{t("language")}</Label>
195
+ <Select value={form.persona.language} onValueChange={(v) => updatePersona("language", v)}>
196
+ <SelectTrigger><SelectValue /></SelectTrigger>
197
+ <SelectContent>
198
+ <SelectItem value="zh-CN">中文(简体)</SelectItem>
199
+ <SelectItem value="zh-TW">中文(繁體)</SelectItem>
200
+ <SelectItem value="en-US">English</SelectItem>
201
+ <SelectItem value="ja-JP">日本語</SelectItem>
202
+ <SelectItem value="ko-KR">한국어</SelectItem>
203
+ </SelectContent>
204
+ </Select>
205
+ </div>
206
+ </div>
207
+ {/* Knowledge Bases */}
208
+ <div className="space-y-2">
209
+ <Label className="text-gray-700 dark:text-gray-300">{t("knowledgeConfig")}</Label>
210
+ <p className="text-xs text-gray-600 dark:text-muted-foreground">{t("knowledgeHint")}</p>
211
+ {kbs.length === 0
212
+ ? <p className="text-sm text-gray-600 dark:text-muted-foreground py-2">{t("noKnowledge")}</p>
213
+ : kbs.map((kb) => (
214
+ <div key={kb.id} className="neo-card flex items-center gap-3 rounded-lg p-3 bg-white dark:bg-slate-800/50 hover:scale-[1.01] transition-transform">
215
+ <Switch id={`kb-${kb.id}`}
216
+ checked={form.knowledgeBases.includes(kb.id)}
217
+ onCheckedChange={() => toggleKb(kb.id)} />
218
+ <Label htmlFor={`kb-${kb.id}`} className="cursor-pointer font-normal flex-1 text-gray-800 dark:text-white">
219
+ {kb.name}
220
+ <span className="ml-2 text-xs text-gray-600 dark:text-muted-foreground">
221
+ {kb.documentCount} 文档 · {kb.chunkCount} 块
222
+ </span>
223
+ </Label>
224
+ </div>
225
+ ))
226
+ }
227
+ </div>
228
+ {/* 工作区路径 */}
229
+ <div className="space-y-1.5">
230
+ <Label className="flex items-center gap-1.5">
231
+ 工作区目录
232
+ <span className="text-xs text-muted-foreground font-normal">(可选)</span>
233
+ </Label>
234
+ <DirectoryPicker
235
+ value={form.workspacePath ?? ""}
236
+ onChange={(p) => update("workspacePath", p)}
237
+ placeholder="~/projects/my-project (留空使用默认工作区)"
238
+ />
239
+ <p className="text-xs text-muted-foreground">
240
+ 数字人使用工具时(bash/read/write)的根目录。留空则自动创建专属工作区。
241
+ </p>
242
+ </div>
243
+ </TabsContent>
244
+
245
+ {/* ── 模型配置 ───────────────────────────────────── */}
246
+ <TabsContent value="model" className="space-y-5">
247
+ <div className="space-y-1.5">
248
+ <Label>{t("modelId")}</Label>
249
+ <ModelSelector providerId={form.model.provider} modelId={form.model.modelId} onChange={handleModelChange} />
250
+ <div className="flex flex-wrap gap-1.5 pt-1">
251
+ <Badge variant="secondary" className="text-xs font-normal">上下文 {fmtN(modelCaps.contextWindow)}</Badge>
252
+ <Badge variant="secondary" className="text-xs font-normal">输出最大 {fmtN(modelCaps.maxTokens)}</Badge>
253
+ {modelCaps.supportsThinking && (
254
+ <Badge className="text-xs font-normal bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0 hover:bg-purple-100">🧠 支持 Thinking</Badge>
255
+ )}
256
+ {!modelCaps.supportsTemperature && (
257
+ <Badge variant="outline" className="text-xs font-normal text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-600">🌡 无 Temperature</Badge>
258
+ )}
259
+ </div>
260
+ </div>
261
+ {modelCaps.supportsThinking && thinkingOptions.length > 0 && (
262
+ <div className="space-y-1.5">
263
+ <Label>{t("thinkingLevel")}</Label>
264
+ <Select value={safeThinkingLevel} onValueChange={(v) => updateModel("thinkingLevel", v)}>
265
+ <SelectTrigger><SelectValue /></SelectTrigger>
266
+ <SelectContent>
267
+ {thinkingOptions.map((l) => (
268
+ <SelectItem key={l} value={l}>{THINKING_LABELS[l] ?? l}</SelectItem>
269
+ ))}
270
+ </SelectContent>
271
+ </Select>
272
+ <p className="text-xs text-muted-foreground">思考深度越高,推理越准确,Token 消耗也会增加。</p>
273
+ </div>
274
+ )}
275
+ {modelCaps.supportsTemperature && (
276
+ <div className="space-y-2">
277
+ <div className="flex items-center justify-between">
278
+ <Label>{t("temperature")}</Label>
279
+ <span className="text-sm tabular-nums text-muted-foreground">{form.model.temperature.toFixed(2)}</span>
280
+ </div>
281
+ <Slider min={0} max={1} step={0.01} value={[form.model.temperature]}
282
+ onValueChange={([v]) => updateModel("temperature", v)} />
283
+ <div className="flex justify-between text-[11px] text-muted-foreground px-0.5">
284
+ <span>精确 0.00</span><span>平衡 0.70</span><span>创意 1.00</span>
285
+ </div>
286
+ </div>
287
+ )}
288
+ <div className="space-y-1.5">
289
+ <div className="flex items-center justify-between">
290
+ <Label>{t("maxTokens")}</Label>
291
+ <span className="text-xs text-muted-foreground">上限 {fmtN(modelCaps.maxTokens)}</span>
292
+ </div>
293
+ <Input type="number" min={256} max={modelCaps.maxTokens || 200000}
294
+ value={form.model.maxTokens}
295
+ onChange={(e) => updateModel("maxTokens", Math.min(Number(e.target.value), modelCaps.maxTokens || 200000))} />
296
+ </div>
297
+ </TabsContent>
298
+
299
+ {/* ── 工具配置(card style)──────────────────────── */}
300
+ <TabsContent value="tools" className="space-y-3">
301
+ <p className="text-xs text-gray-600 dark:text-muted-foreground">
302
+ 开启后数字人可在对话中使用对应工具。点击卡片切换。
303
+ </p>
304
+ <div className="grid grid-cols-1 gap-2">
305
+ {TOOL_CARDS.map(({ key, icon: Icon, color, labelKey, hintKey }) => {
306
+ const enabled = form.tools[key];
307
+ return (
308
+ <button
309
+ key={key}
310
+ type="button"
311
+ onClick={() => toggleTool(key)}
312
+ className={cn(
313
+ "flex items-center gap-4 rounded-xl p-4 text-left transition-all",
314
+ enabled
315
+ ? "neo-card-inset border-2 border-blue-200 bg-blue-50 dark:border-[#57BE6B]/60 dark:bg-[#57BE6B]/20 shadow-sm"
316
+ : "neo-button bg-white dark:bg-transparent border border-gray-200 dark:border-border hover:border-blue-300 dark:hover:border-zinc-600 hover:scale-[1.01]",
317
+ )}
318
+ >
319
+ {/* Icon */}
320
+ <div className={cn("h-10 w-10 rounded-xl flex items-center justify-center shrink-0", color)}>
321
+ <Icon className="h-5 w-5" />
322
+ </div>
323
+ {/* Text */}
324
+ <div className="flex-1 min-w-0">
325
+ <p className="text-sm font-medium text-gray-800 dark:text-white">
326
+ {t(`tools.${labelKey}` as Parameters<typeof t>[0])}
327
+ </p>
328
+ <p className="text-xs text-gray-600 dark:text-muted-foreground mt-0.5">
329
+ {t(`tools.${hintKey}` as Parameters<typeof t>[0])}
330
+ </p>
331
+ </div>
332
+ {/* Toggle knob */}
333
+ <ToggleKnob enabled={enabled} />
334
+ </button>
335
+ );
336
+ })}
337
+
338
+ {/* Voice 语音功能 */}
339
+ <button
340
+ type="button"
341
+ onClick={() => setForm(p => ({ ...p, voice: { enabled: !p.voice.enabled } }))}
342
+ className={cn(
343
+ "flex items-center gap-4 rounded-xl p-4 text-left transition-all",
344
+ form.voice.enabled
345
+ ? "neo-card-inset border-2 border-green-200 bg-green-50 dark:border-green-500/60 dark:bg-green-500/20 shadow-sm"
346
+ : "neo-button bg-white dark:bg-transparent border border-gray-200 dark:border-border hover:border-green-300 dark:hover:border-zinc-600 hover:scale-[1.01]",
347
+ )}
348
+ >
349
+ <div className={cn(
350
+ "h-10 w-10 rounded-xl flex items-center justify-center shrink-0",
351
+ form.voice.enabled ? "bg-green-500 text-white" : "bg-gray-100 text-gray-500 dark:bg-muted dark:text-muted-foreground"
352
+ )}>
353
+ <Volume2 className="h-5 w-5" />
354
+ </div>
355
+ <div className="flex-1">
356
+ <p className="text-sm font-semibold text-gray-800 dark:text-white">语音功能</p>
357
+ <p className="text-xs text-gray-600 dark:text-muted-foreground mt-0.5">
358
+ 启用语音朗读功能(全局供应商在设置中配置)
359
+ </p>
360
+ </div>
361
+ <ToggleKnob enabled={form.voice.enabled} />
362
+ </button>
363
+ </div>
364
+ </TabsContent>
365
+
366
+ {/* ── Skills 管理 ────────────────────────────────── */}
367
+ <TabsContent value="skills" className="space-y-3">
368
+ <div className="neo-card flex items-start gap-3 rounded-xl border-2 border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 p-4">
369
+ <div className="flex-1">
370
+ <p className="text-sm text-gray-700 dark:text-gray-300">
371
+ Skills 功能允许agent使用自定义工具和能力。
372
+ </p>
373
+ </div>
374
+ </div>
375
+ </TabsContent>
376
+ </Tabs>
377
+
378
+ <div className="mt-6 flex justify-end">
379
+ <Button type="submit" disabled={saving || !form.name.trim()} className="neo-button 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">
380
+ {saving ? tc("loading") : (submitLabel ?? tc("save"))}
381
+ </Button>
382
+ </div>
383
+ </form>
384
+ );
385
+ }
386
+
387
+ function fmtN(n: number): string {
388
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
389
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
390
+ return String(n);
391
+ }
392
+
393
+ /** 统一风格的滑块开关指示器 */
394
+ function ToggleKnob({ enabled, color = "default" }: { enabled: boolean; color?: "default" | "green" }) {
395
+ const onBg = color === "green" ? "bg-[#57BE6B]" : "bg-[#57BE6B]";
396
+ return (
397
+ <div className={cn(
398
+ "h-[22px] w-10 rounded-full transition-colors duration-200 shrink-0 relative",
399
+ enabled ? onBg : "bg-zinc-300 dark:bg-zinc-600",
400
+ )}>
401
+ <div className={cn(
402
+ "absolute top-[3px] h-4 w-4 rounded-full bg-white shadow-md transition-transform duration-200",
403
+ enabled ? "translate-x-[19px]" : "translate-x-[3px]",
404
+ )} />
405
+ </div>
406
+ );
407
+ }
408
+
409
+ // ─── AgentSkillToggleList — only enable/disable, no install ──────────────────
410
+ function AgentSkillToggleList({
411
+ enabledSkills, onChange,
412
+ }: { enabledSkills: string[]; onChange: (s: string[]) => void }) {
413
+ const [installed, setInstalled] = useState<{ name: string; description: string }[]>([]);
414
+ useEffect(() => {
415
+ fetch("/api/skills").then(r => r.json()).then(setInstalled).catch(() => {});
416
+ }, []);
417
+
418
+ if (installed.length === 0) {
419
+ return (
420
+ <div className="neo-card rounded-xl border-2 border-dashed border-gray-300 dark:border-slate-700 p-6 text-center text-sm text-gray-600 dark:text-muted-foreground bg-white dark:bg-slate-800/30">
421
+ 还没有安装任何 Skill。前往
422
+ <Link href="/zh/settings" className="underline mx-1 text-purple-600 dark:text-purple-400">系统设置 → Skills 管理</Link>
423
+ 安装。
424
+ </div>
425
+ );
426
+ }
427
+
428
+ return (
429
+ <div className="space-y-2">
430
+ {installed.map((skill) => {
431
+ const enabled = enabledSkills.includes(skill.name);
432
+ return (
433
+ <button key={skill.name} type="button"
434
+ onClick={() => onChange(enabled ? enabledSkills.filter(s => s !== skill.name) : [...enabledSkills, skill.name])}
435
+ className={cn(
436
+ "w-full flex items-center gap-3 rounded-xl p-3.5 text-left transition-all",
437
+ enabled
438
+ ? "neo-card-inset border-2 border-blue-200 bg-blue-50 dark:border-[#57BE6B]/60 dark:bg-[#57BE6B]/20 shadow-sm"
439
+ : "neo-button bg-white dark:bg-transparent border border-gray-200 dark:border-border hover:border-blue-300 dark:hover:border-zinc-600 hover:scale-[1.01]",
440
+ )}>
441
+ <div className={cn(
442
+ "h-5 w-5 rounded-full shrink-0 flex items-center justify-center transition-all",
443
+ enabled ? "bg-blue-500 dark:bg-[#57BE6B]" : "bg-gray-200 dark:bg-zinc-600 border-2 border-gray-300 dark:border-zinc-500",
444
+ )}>
445
+ {enabled && <Check className="h-3 w-3 text-white stroke-[3]" />}
446
+ </div>
447
+ <div className="flex-1 min-w-0">
448
+ <p className="font-mono text-sm font-medium text-gray-800 dark:text-white">{skill.name}</p>
449
+ <p className="text-xs text-gray-600 dark:text-muted-foreground mt-0.5 truncate">{skill.description || "暂无描述"}</p>
450
+ </div>
451
+ <ToggleKnob enabled={enabled} />
452
+ </button>
453
+ );
454
+ })}
455
+ </div>
456
+ );
457
+ }