@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.
- package/README.md +37 -0
- package/eslint.config.mjs +20 -0
- package/messages/en.json +184 -0
- package/messages/zh.json +184 -0
- package/middleware.ts +9 -0
- package/next-env.d.ts +5 -0
- package/next.config.ts +16 -0
- package/package.json +59 -0
- package/postcss.config.mjs +5 -0
- package/src/app/[locale]/agents/[id]/chat/page.tsx +833 -0
- package/src/app/[locale]/agents/[id]/edit/page.tsx +58 -0
- package/src/app/[locale]/agents/new/page.tsx +52 -0
- package/src/app/[locale]/agents/page.tsx +122 -0
- package/src/app/[locale]/knowledge/page.tsx +305 -0
- package/src/app/[locale]/layout.tsx +46 -0
- package/src/app/[locale]/page.tsx +6 -0
- package/src/app/[locale]/settings/page.tsx +1204 -0
- package/src/app/api/agents/[id]/route.ts +27 -0
- package/src/app/api/agents/route.ts +27 -0
- package/src/app/api/auth-config/test/route.ts +69 -0
- package/src/app/api/bash-output/route.ts +57 -0
- package/src/app/api/chat/[agentId]/route.ts +164 -0
- package/src/app/api/env-vars/[key]/route.ts +22 -0
- package/src/app/api/env-vars/route.ts +26 -0
- package/src/app/api/env-vars/scan/route.ts +66 -0
- package/src/app/api/file-serve/route.ts +58 -0
- package/src/app/api/fs/browse/route.ts +47 -0
- package/src/app/api/knowledge/[id]/documents/[docId]/route.ts +22 -0
- package/src/app/api/knowledge/[id]/route.ts +21 -0
- package/src/app/api/knowledge/[id]/upload/route.ts +90 -0
- package/src/app/api/knowledge/route.ts +15 -0
- package/src/app/api/knowledge/search/route.ts +26 -0
- package/src/app/api/models/route.ts +160 -0
- package/src/app/api/models-config/route.ts +41 -0
- package/src/app/api/models-config/test/route.ts +84 -0
- package/src/app/api/sessions/[agentId]/[sessionId]/route.ts +12 -0
- package/src/app/api/sessions/[agentId]/route.ts +19 -0
- package/src/app/api/sessions/history/route.ts +166 -0
- package/src/app/api/settings/route.ts +45 -0
- package/src/app/api/skills/[name]/route.ts +40 -0
- package/src/app/api/skills/install/route.ts +35 -0
- package/src/app/api/skills/route.ts +54 -0
- package/src/app/api/skills/search/route.ts +60 -0
- package/src/app/api/tts/route.ts +235 -0
- package/src/app/api/voice-provider/route.ts +98 -0
- package/src/app/api/workspace-files/route.ts +151 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +13 -0
- package/src/app/page.tsx +6 -0
- package/src/components/agents/agent-form.tsx +457 -0
- package/src/components/agents/model-selector.tsx +290 -0
- package/src/components/agents/skills-manager.tsx +347 -0
- package/src/components/chat/input-bar.tsx +561 -0
- package/src/components/chat/message-bubble.tsx +395 -0
- package/src/components/layout/sidebar.tsx +304 -0
- package/src/components/providers.tsx +19 -0
- package/src/components/ui/badge.tsx +26 -0
- package/src/components/ui/button.tsx +43 -0
- package/src/components/ui/card.tsx +46 -0
- package/src/components/ui/dialog.tsx +74 -0
- package/src/components/ui/directory-picker.tsx +185 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/label.tsx +16 -0
- package/src/components/ui/scroll-area.tsx +41 -0
- package/src/components/ui/select.tsx +93 -0
- package/src/components/ui/slider.tsx +35 -0
- package/src/components/ui/switch.tsx +35 -0
- package/src/components/ui/tabs.tsx +48 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toaster.tsx +50 -0
- package/src/hooks/use-toast.ts +43 -0
- package/src/i18n/request.ts +13 -0
- package/src/i18n/routing.ts +7 -0
- package/src/lib/agent-factory.ts +200 -0
- package/src/lib/agents/store.ts +95 -0
- package/src/lib/db/client.ts +25 -0
- package/src/lib/db/schema.ts +66 -0
- package/src/lib/env-vars.ts +33 -0
- package/src/lib/knowledge/chunker.ts +34 -0
- package/src/lib/knowledge/embedder.ts +48 -0
- package/src/lib/knowledge/retriever.ts +94 -0
- package/src/lib/knowledge/store.ts +74 -0
- package/src/lib/models-config.ts +55 -0
- package/src/lib/settings.ts +33 -0
- package/src/lib/skills.ts +79 -0
- package/src/lib/system-prompt.ts +52 -0
- package/src/lib/tool-builder.ts +19 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/voice-providers-config.ts +71 -0
- package/src/middleware.ts +9 -0
- package/src/types/agent.ts +124 -0
- package/src/types/knowledge.ts +39 -0
- package/src/types/settings.ts +31 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import { Check, ChevronDown, Brain, Thermometer, Cpu, DollarSign, Search } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
8
|
+
import {
|
|
9
|
+
Dialog, DialogContent, DialogHeader, DialogTitle,
|
|
10
|
+
} from "@/components/ui/dialog";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
import type { ProviderGroup, ModelInfo } from "@/app/api/models/route";
|
|
13
|
+
|
|
14
|
+
export interface ModelCaps {
|
|
15
|
+
supportsThinking: boolean;
|
|
16
|
+
supportsTemperature: boolean;
|
|
17
|
+
thinkingLevels: string[];
|
|
18
|
+
contextWindow: number;
|
|
19
|
+
maxTokens: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ModelSelectorProps {
|
|
23
|
+
providerId: string;
|
|
24
|
+
modelId: string;
|
|
25
|
+
onChange: (providerId: string, modelId: string, caps: ModelCaps) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Eagerly resolve caps from the global model cache
|
|
29
|
+
let _groupsCache: ProviderGroup[] | null = null;
|
|
30
|
+
let _groupsPromise: Promise<ProviderGroup[]> | null = null;
|
|
31
|
+
|
|
32
|
+
function fetchGroups(): Promise<ProviderGroup[]> {
|
|
33
|
+
if (_groupsCache) return Promise.resolve(_groupsCache);
|
|
34
|
+
if (_groupsPromise) return _groupsPromise;
|
|
35
|
+
_groupsPromise = fetch("/api/models")
|
|
36
|
+
.then((r) => r.json())
|
|
37
|
+
.then((data: ProviderGroup[]) => { _groupsCache = data; return data; });
|
|
38
|
+
return _groupsPromise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findModel(groups: ProviderGroup[], providerId: string, modelId: string): ModelInfo | undefined {
|
|
42
|
+
return groups.find((g) => g.id === providerId)?.models.find((m) => m.id === modelId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function capsFromModel(m: ModelInfo): ModelCaps {
|
|
46
|
+
return {
|
|
47
|
+
supportsThinking: m.supportsThinking,
|
|
48
|
+
supportsTemperature: m.supportsTemperature,
|
|
49
|
+
thinkingLevels: m.thinkingLevels,
|
|
50
|
+
contextWindow: m.contextWindow,
|
|
51
|
+
maxTokens: m.maxTokens,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useModelCaps(providerId: string, modelId: string): ModelCaps {
|
|
56
|
+
const [caps, setCaps] = useState<ModelCaps>({
|
|
57
|
+
supportsThinking: false,
|
|
58
|
+
supportsTemperature: true,
|
|
59
|
+
thinkingLevels: [],
|
|
60
|
+
contextWindow: 200000,
|
|
61
|
+
maxTokens: 8192,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!providerId || !modelId) return;
|
|
66
|
+
fetchGroups().then((groups) => {
|
|
67
|
+
const m = findModel(groups, providerId, modelId);
|
|
68
|
+
if (m) setCaps(capsFromModel(m));
|
|
69
|
+
});
|
|
70
|
+
}, [providerId, modelId]);
|
|
71
|
+
|
|
72
|
+
return caps;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ModelSelector({ providerId, modelId, onChange }: ModelSelectorProps) {
|
|
76
|
+
const [open, setOpen] = useState(false);
|
|
77
|
+
const [groups, setGroups] = useState<ProviderGroup[]>(_groupsCache ?? []);
|
|
78
|
+
const [loading, setLoading] = useState(false);
|
|
79
|
+
const [search, setSearch] = useState("");
|
|
80
|
+
const [activePid, setActivePid] = useState<string>(providerId || "anthropic");
|
|
81
|
+
|
|
82
|
+
// Pre-load groups on mount
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (_groupsCache) { setGroups(_groupsCache); return; }
|
|
85
|
+
setLoading(true);
|
|
86
|
+
fetchGroups().then((data) => setGroups(data)).finally(() => setLoading(false));
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
// Keep active provider in sync with prop
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (providerId) setActivePid(providerId);
|
|
92
|
+
}, [providerId]);
|
|
93
|
+
|
|
94
|
+
const handleOpen = useCallback(() => {
|
|
95
|
+
setActivePid(providerId || groups[0]?.id || "anthropic");
|
|
96
|
+
setSearch("");
|
|
97
|
+
setOpen(true);
|
|
98
|
+
}, [providerId, groups]);
|
|
99
|
+
|
|
100
|
+
// Label for trigger button
|
|
101
|
+
const currentGroup = groups.find((g) => g.id === providerId);
|
|
102
|
+
const currentModel = currentGroup?.models.find((m) => m.id === modelId);
|
|
103
|
+
const triggerLabel = currentModel
|
|
104
|
+
? `${currentGroup?.name} / ${currentModel.name}`
|
|
105
|
+
: providerId && modelId
|
|
106
|
+
? `${providerId} / ${modelId}`
|
|
107
|
+
: "选择模型…";
|
|
108
|
+
|
|
109
|
+
const activeGroup = groups.find((g) => g.id === activePid);
|
|
110
|
+
const visibleModels = (activeGroup?.models ?? []).filter((m) => {
|
|
111
|
+
if (!search) return true;
|
|
112
|
+
const q = search.toLowerCase();
|
|
113
|
+
return m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const handleSelect = (m: ModelInfo) => {
|
|
117
|
+
onChange(m.providerId, m.id, capsFromModel(m));
|
|
118
|
+
setOpen(false);
|
|
119
|
+
setSearch("");
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
{/* Trigger */}
|
|
125
|
+
<Button
|
|
126
|
+
type="button"
|
|
127
|
+
variant="outline"
|
|
128
|
+
onClick={handleOpen}
|
|
129
|
+
className="w-full justify-between font-normal h-10"
|
|
130
|
+
>
|
|
131
|
+
<span className="truncate text-sm">{triggerLabel}</span>
|
|
132
|
+
<ChevronDown className="ml-2 h-4 w-4 flex-shrink-0 opacity-50" />
|
|
133
|
+
</Button>
|
|
134
|
+
|
|
135
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
136
|
+
<DialogContent className="max-w-2xl p-0 gap-0 overflow-hidden">
|
|
137
|
+
<DialogHeader className="px-4 pt-4 pb-3 border-b shrink-0">
|
|
138
|
+
<DialogTitle className="text-base">选择模型</DialogTitle>
|
|
139
|
+
</DialogHeader>
|
|
140
|
+
|
|
141
|
+
<div className="flex" style={{ height: 520 }}>
|
|
142
|
+
{/* Left: provider list */}
|
|
143
|
+
<div className="w-48 shrink-0 border-r flex flex-col">
|
|
144
|
+
<div className="px-2 py-1.5 border-b">
|
|
145
|
+
<p className="text-xs font-medium text-muted-foreground px-1">提供商</p>
|
|
146
|
+
</div>
|
|
147
|
+
<ScrollArea className="flex-1">
|
|
148
|
+
<div className="p-1.5 space-y-0.5">
|
|
149
|
+
{loading && !groups.length && (
|
|
150
|
+
<p className="text-xs text-muted-foreground text-center py-6">加载中…</p>
|
|
151
|
+
)}
|
|
152
|
+
{groups.map((g) => {
|
|
153
|
+
const isActive = g.id === activePid;
|
|
154
|
+
const isSelected = g.id === providerId;
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
key={g.id}
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={() => { setActivePid(g.id); setSearch(""); }}
|
|
160
|
+
className={cn(
|
|
161
|
+
"w-full text-left rounded-md px-2.5 py-2 transition-colors",
|
|
162
|
+
isActive ? "bg-secondary" : "hover:bg-secondary/40",
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<div className="flex items-center gap-1.5">
|
|
166
|
+
{isSelected && <Check className="h-3 w-3 text-primary shrink-0" />}
|
|
167
|
+
<span className={cn("text-sm truncate", !isActive && "text-muted-foreground")}>
|
|
168
|
+
{g.name}
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
<span className="text-[11px] text-muted-foreground pl-0">
|
|
172
|
+
{g.models.length} 个模型
|
|
173
|
+
</span>
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</div>
|
|
178
|
+
</ScrollArea>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Right: model list */}
|
|
182
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
183
|
+
<div className="px-2 py-1.5 border-b shrink-0">
|
|
184
|
+
<div className="relative">
|
|
185
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
186
|
+
<Input
|
|
187
|
+
placeholder="搜索模型名称或 ID…"
|
|
188
|
+
value={search}
|
|
189
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
190
|
+
className="pl-8 h-8 text-sm"
|
|
191
|
+
autoFocus
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<ScrollArea className="flex-1">
|
|
196
|
+
<div className="p-2 space-y-1">
|
|
197
|
+
{visibleModels.length === 0 && (
|
|
198
|
+
<p className="text-sm text-muted-foreground text-center py-10">
|
|
199
|
+
{search ? "无匹配模型" : "该提供商暂无模型"}
|
|
200
|
+
</p>
|
|
201
|
+
)}
|
|
202
|
+
{visibleModels.map((m) => {
|
|
203
|
+
const isSelected = m.providerId === providerId && m.id === modelId;
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
key={m.id}
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => handleSelect(m)}
|
|
209
|
+
className={cn(
|
|
210
|
+
"w-full text-left rounded-lg px-3 py-2.5 border transition-colors",
|
|
211
|
+
isSelected
|
|
212
|
+
? "border-primary/50 bg-primary/5 dark:bg-primary/10"
|
|
213
|
+
: "border-transparent hover:border-border hover:bg-secondary/40",
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{/* Row 1: name + price */}
|
|
217
|
+
<div className="flex items-start justify-between gap-2">
|
|
218
|
+
<div className="min-w-0 flex-1">
|
|
219
|
+
<div className="flex items-center gap-1.5">
|
|
220
|
+
<span className="text-sm font-medium leading-tight">{m.name}</span>
|
|
221
|
+
{isSelected && (
|
|
222
|
+
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
<p className="text-[11px] text-muted-foreground font-mono truncate mt-0.5">
|
|
226
|
+
{m.id}
|
|
227
|
+
</p>
|
|
228
|
+
</div>
|
|
229
|
+
{m.costInput !== undefined && (
|
|
230
|
+
<span className="text-[11px] text-muted-foreground flex items-center gap-0.5 shrink-0 mt-0.5">
|
|
231
|
+
<DollarSign className="h-2.5 w-2.5" />
|
|
232
|
+
{m.costInput}/{m.costOutput}/M
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Row 2: capability tags */}
|
|
238
|
+
<div className="flex flex-wrap gap-1 mt-2">
|
|
239
|
+
<CapTag icon={<Cpu className="h-2.5 w-2.5" />} label={fmtCtx(m.contextWindow)} />
|
|
240
|
+
{m.supportsThinking && (
|
|
241
|
+
<CapTag
|
|
242
|
+
icon={<Brain className="h-2.5 w-2.5" />}
|
|
243
|
+
label={m.thinkingLevels.length > 0
|
|
244
|
+
? `Thinking (${m.thinkingLevels.join("/")})`
|
|
245
|
+
: "Thinking"}
|
|
246
|
+
className="text-purple-600 dark:text-purple-400 border-purple-300 dark:border-purple-700"
|
|
247
|
+
/>
|
|
248
|
+
)}
|
|
249
|
+
{!m.supportsTemperature && (
|
|
250
|
+
<CapTag
|
|
251
|
+
icon={<Thermometer className="h-2.5 w-2.5" />}
|
|
252
|
+
label="无 Temp"
|
|
253
|
+
className="text-amber-600 dark:text-amber-400 border-amber-300 dark:border-amber-700"
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
{m.inputTypes.includes("image") && (
|
|
257
|
+
<CapTag label="👁 Vision" />
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</button>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
</ScrollArea>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</DialogContent>
|
|
268
|
+
</Dialog>
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function CapTag({
|
|
274
|
+
icon, label, className,
|
|
275
|
+
}: { icon?: React.ReactNode; label: string; className?: string }) {
|
|
276
|
+
return (
|
|
277
|
+
<span className={cn(
|
|
278
|
+
"inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground",
|
|
279
|
+
className,
|
|
280
|
+
)}>
|
|
281
|
+
{icon}{label}
|
|
282
|
+
</span>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function fmtCtx(n: number): string {
|
|
287
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M ctx`;
|
|
288
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K ctx`;
|
|
289
|
+
return `${n} ctx`;
|
|
290
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Search, Trash2, Check, Loader2, Download,
|
|
6
|
+
Plus, ChevronDown, XCircle, ExternalLink, RefreshCw,
|
|
7
|
+
} from "lucide-react";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Badge } from "@/components/ui/badge";
|
|
11
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
12
|
+
import { Label } from "@/components/ui/label";
|
|
13
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
14
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
|
|
17
|
+
export interface SkillInfo {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
filePath: string;
|
|
21
|
+
source: string;
|
|
22
|
+
hasSetup: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SearchResult {
|
|
26
|
+
package: string;
|
|
27
|
+
name: string;
|
|
28
|
+
installs: number;
|
|
29
|
+
installsDisplay: string;
|
|
30
|
+
url: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Main SkillsManager (settings page) ──────────────────────────────────────
|
|
34
|
+
interface SkillsManagerProps {
|
|
35
|
+
enabledSkills?: string[];
|
|
36
|
+
onChange?: (skills: string[]) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SkillsManager({ enabledSkills, onChange }: SkillsManagerProps) {
|
|
40
|
+
const [installed, setInstalled] = useState<SkillInfo[]>([]);
|
|
41
|
+
const [loading, setLoading] = useState(false);
|
|
42
|
+
const [showInstall, setShowInstall] = useState(false);
|
|
43
|
+
|
|
44
|
+
const load = useCallback(() => {
|
|
45
|
+
setLoading(true);
|
|
46
|
+
fetch("/api/skills").then(r => r.json()).then(setInstalled).finally(() => setLoading(false));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
useEffect(() => { load(); }, [load]);
|
|
50
|
+
|
|
51
|
+
const uninstall = async (name: string) => {
|
|
52
|
+
await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
53
|
+
setInstalled(prev => prev.filter(s => s.name !== name));
|
|
54
|
+
if (onChange && enabledSkills) onChange(enabledSkills.filter(s => s !== name));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="space-y-4">
|
|
59
|
+
{/* Header row */}
|
|
60
|
+
<div className="flex items-center justify-between">
|
|
61
|
+
<div className="text-sm text-muted-foreground">
|
|
62
|
+
已安装 <span className="font-medium text-foreground">{installed.length}</span> 个 Skill
|
|
63
|
+
</div>
|
|
64
|
+
<div className="flex items-center gap-2">
|
|
65
|
+
<Button variant="ghost" size="sm" onClick={load} disabled={loading} className="gap-1.5 h-8">
|
|
66
|
+
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
|
67
|
+
刷新
|
|
68
|
+
</Button>
|
|
69
|
+
<Button variant="outline" size="sm" onClick={() => setShowInstall(true)} className="gap-1.5 h-8">
|
|
70
|
+
<Plus className="h-3.5 w-3.5" />
|
|
71
|
+
安装 Skill
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Installed list */}
|
|
77
|
+
{loading ? (
|
|
78
|
+
<div className="flex items-center justify-center py-10 text-muted-foreground text-sm">
|
|
79
|
+
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
|
|
80
|
+
</div>
|
|
81
|
+
) : installed.length === 0 ? (
|
|
82
|
+
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
|
|
83
|
+
<p className="mb-2">还没有安装任何 Skill</p>
|
|
84
|
+
<Button variant="outline" size="sm" onClick={() => setShowInstall(true)} className="gap-1.5">
|
|
85
|
+
<Plus className="h-3.5 w-3.5" />安装第一个 Skill
|
|
86
|
+
</Button>
|
|
87
|
+
</div>
|
|
88
|
+
) : (
|
|
89
|
+
<div className="space-y-2">
|
|
90
|
+
{installed.map(skill => (
|
|
91
|
+
<div key={skill.name}
|
|
92
|
+
className="group flex items-start gap-3 rounded-xl border p-3.5 hover:bg-secondary/20 transition-colors">
|
|
93
|
+
<div className="flex-1 min-w-0">
|
|
94
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
95
|
+
<span className="font-mono text-sm font-medium">{skill.name}</span>
|
|
96
|
+
{skill.hasSetup && (
|
|
97
|
+
<Badge variant="outline" className="text-[10px] h-4 px-1.5 font-normal">含 Setup</Badge>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{skill.description || "暂无描述"}</p>
|
|
101
|
+
<p className="text-[10px] text-muted-foreground/60 mt-0.5 font-mono truncate">{skill.filePath}</p>
|
|
102
|
+
</div>
|
|
103
|
+
<button type="button" onClick={() => uninstall(skill.name)}
|
|
104
|
+
title="卸载"
|
|
105
|
+
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all shrink-0">
|
|
106
|
+
<Trash2 className="h-4 w-4" />
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<InstallDialog open={showInstall} onClose={() => setShowInstall(false)} onInstalled={load} installed={installed} />
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── InstallDialog — community search + URL + paste ──────────────────────────
|
|
119
|
+
function InstallDialog({
|
|
120
|
+
open, onClose, onInstalled, installed,
|
|
121
|
+
}: { open: boolean; onClose: () => void; onInstalled: () => void; installed: SkillInfo[] }) {
|
|
122
|
+
const installedNames = new Set(installed.map(s => s.name));
|
|
123
|
+
const [tab, setTab] = useState<"search" | "url" | "paste">("search");
|
|
124
|
+
const [query, setQuery] = useState("");
|
|
125
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
126
|
+
const [searching, setSearching] = useState(false);
|
|
127
|
+
const [searchErr, setSearchErr] = useState("");
|
|
128
|
+
const [installing, setInstalling] = useState<string | null>(null);
|
|
129
|
+
const [installStatus, setInstallStatus] = useState<Record<string, "ok" | "err">>({});
|
|
130
|
+
|
|
131
|
+
// URL tab
|
|
132
|
+
const [url, setUrl] = useState("");
|
|
133
|
+
// Paste tab
|
|
134
|
+
const [content, setContent] = useState("");
|
|
135
|
+
const [name, setName] = useState("");
|
|
136
|
+
// Common install status for URL/paste
|
|
137
|
+
const [status, setStatus] = useState<"idle" | "loading" | "ok" | "err">("idle");
|
|
138
|
+
const [errMsg, setErrMsg] = useState("");
|
|
139
|
+
|
|
140
|
+
const searchTimer = useRef<NodeJS.Timeout | null>(null);
|
|
141
|
+
|
|
142
|
+
const doSearch = useCallback(async (q: string) => {
|
|
143
|
+
if (!q.trim()) { setResults([]); return; }
|
|
144
|
+
setSearching(true); setSearchErr("");
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch("/api/skills/search", {
|
|
147
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ query: q.trim(), limit: 30 }),
|
|
149
|
+
});
|
|
150
|
+
const data = await res.json() as { results?: SearchResult[]; error?: string };
|
|
151
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
152
|
+
setResults(data.results ?? []);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
setSearchErr(String(e));
|
|
155
|
+
setResults([]);
|
|
156
|
+
} finally { setSearching(false); }
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
// Debounced search
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (tab !== "search") return;
|
|
162
|
+
if (searchTimer.current) clearTimeout(searchTimer.current);
|
|
163
|
+
searchTimer.current = setTimeout(() => doSearch(query), 400);
|
|
164
|
+
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
|
|
165
|
+
}, [query, tab, doSearch]);
|
|
166
|
+
|
|
167
|
+
const installFromRegistry = async (pkg: string) => {
|
|
168
|
+
setInstalling(pkg);
|
|
169
|
+
try {
|
|
170
|
+
const res = await fetch("/api/skills/install", {
|
|
171
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
172
|
+
body: JSON.stringify({ package: pkg }),
|
|
173
|
+
});
|
|
174
|
+
const d = await res.json() as { success?: boolean; error?: string };
|
|
175
|
+
if (!res.ok || !d.success) throw new Error(d.error ?? "安装失败");
|
|
176
|
+
setInstallStatus(p => ({ ...p, [pkg]: "ok" }));
|
|
177
|
+
onInstalled();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
setInstallStatus(p => ({ ...p, [pkg]: "err" }));
|
|
180
|
+
console.error(e);
|
|
181
|
+
} finally { setInstalling(null); }
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const installManual = async () => {
|
|
185
|
+
setStatus("loading"); setErrMsg("");
|
|
186
|
+
try {
|
|
187
|
+
const body = tab === "url" ? { url: url.trim() } : { content: content.trim(), name: name.trim() };
|
|
188
|
+
const res = await fetch("/api/skills", {
|
|
189
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
190
|
+
body: JSON.stringify(body),
|
|
191
|
+
});
|
|
192
|
+
const d = await res.json() as { error?: string };
|
|
193
|
+
if (!res.ok) throw new Error(d.error ?? "安装失败");
|
|
194
|
+
setStatus("ok"); onInstalled();
|
|
195
|
+
setTimeout(() => { onClose(); reset(); }, 1200);
|
|
196
|
+
} catch (e) { setStatus("err"); setErrMsg(String(e)); }
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const reset = () => {
|
|
200
|
+
setQuery(""); setResults([]); setSearchErr("");
|
|
201
|
+
setUrl(""); setContent(""); setName("");
|
|
202
|
+
setStatus("idle"); setErrMsg(""); setInstallStatus({});
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const skillName = (pkg: string) => pkg.split("@").pop() ?? pkg;
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Dialog open={open} onOpenChange={v => { if (!v) { onClose(); reset(); } }}>
|
|
209
|
+
<DialogContent className="max-w-xl max-h-[85vh] flex flex-col">
|
|
210
|
+
<DialogHeader>
|
|
211
|
+
<DialogTitle>安装 Skill</DialogTitle>
|
|
212
|
+
</DialogHeader>
|
|
213
|
+
|
|
214
|
+
<Tabs value={tab} onValueChange={v => setTab(v as typeof tab)} className="flex-1 flex flex-col min-h-0">
|
|
215
|
+
<TabsList className="shrink-0">
|
|
216
|
+
<TabsTrigger value="search">🔍 社区搜索</TabsTrigger>
|
|
217
|
+
<TabsTrigger value="url">🔗 URL 安装</TabsTrigger>
|
|
218
|
+
<TabsTrigger value="paste">📋 粘贴内容</TabsTrigger>
|
|
219
|
+
</TabsList>
|
|
220
|
+
|
|
221
|
+
{/* Community search */}
|
|
222
|
+
<TabsContent value="search" className="flex-1 flex flex-col min-h-0 space-y-3 mt-3">
|
|
223
|
+
<div className="relative shrink-0">
|
|
224
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
225
|
+
<Input
|
|
226
|
+
value={query} onChange={e => setQuery(e.target.value)}
|
|
227
|
+
placeholder="搜索社区 Skills,例如:web search、pdf、code review…"
|
|
228
|
+
className="pl-9"
|
|
229
|
+
autoFocus
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="flex-1 overflow-y-auto min-h-0 space-y-1.5 pr-1">
|
|
234
|
+
{searching && (
|
|
235
|
+
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
|
236
|
+
<Loader2 className="h-4 w-4 animate-spin mr-2" />搜索中…
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
{!searching && searchErr && (
|
|
240
|
+
<div className="py-8 text-center text-sm text-destructive">{searchErr}</div>
|
|
241
|
+
)}
|
|
242
|
+
{!searching && !searchErr && query && results.length === 0 && (
|
|
243
|
+
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
244
|
+
未找到「{query}」相关 Skill
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
{!searching && !searchErr && !query && (
|
|
248
|
+
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
249
|
+
输入关键词搜索 <a href="https://skills.sh" target="_blank" rel="noopener noreferrer" className="underline">skills.sh</a> 社区 Skills
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
{results.map(r => {
|
|
253
|
+
const st = installStatus[r.package];
|
|
254
|
+
const busy = installing === r.package;
|
|
255
|
+
const alreadyInstalled = installedNames.has(r.name);
|
|
256
|
+
return (
|
|
257
|
+
<div key={r.package} className={cn(
|
|
258
|
+
"flex items-start gap-3 rounded-xl border p-3 transition-colors",
|
|
259
|
+
alreadyInstalled ? "bg-[#57BE6B]/5 border-[#57BE6B]/30" : "hover:bg-secondary/30",
|
|
260
|
+
)}>
|
|
261
|
+
<div className="flex-1 min-w-0">
|
|
262
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
263
|
+
<span className="font-mono text-sm font-medium">{r.name}</span>
|
|
264
|
+
{alreadyInstalled && (
|
|
265
|
+
<Badge className="text-[10px] h-4 px-1.5 bg-[#57BE6B]/15 text-[#57BE6B] border-0 font-normal">
|
|
266
|
+
已安装
|
|
267
|
+
</Badge>
|
|
268
|
+
)}
|
|
269
|
+
{r.installs > 0 && (
|
|
270
|
+
<span className="text-[10px] text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">
|
|
271
|
+
↓ {r.installsDisplay}
|
|
272
|
+
</span>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
<p className="text-[11px] font-mono text-muted-foreground/70 mt-0.5">{r.package}</p>
|
|
276
|
+
<a href={r.url} target="_blank" rel="noopener noreferrer"
|
|
277
|
+
className="text-[10px] text-blue-500 hover:underline flex items-center gap-0.5 mt-0.5 w-fit">
|
|
278
|
+
<ExternalLink className="h-2.5 w-2.5" />查看详情
|
|
279
|
+
</a>
|
|
280
|
+
</div>
|
|
281
|
+
{alreadyInstalled ? (
|
|
282
|
+
<div className="flex items-center gap-1 h-7 px-2 text-xs text-[#57BE6B] shrink-0">
|
|
283
|
+
<Check className="h-3.5 w-3.5" />已安装
|
|
284
|
+
</div>
|
|
285
|
+
) : (
|
|
286
|
+
<Button size="sm" variant={st === "ok" ? "ghost" : "outline"}
|
|
287
|
+
className={cn("h-7 text-xs shrink-0 gap-1 min-w-[56px]",
|
|
288
|
+
st === "ok" && "text-green-600 dark:text-green-400")}
|
|
289
|
+
disabled={busy || st === "ok"}
|
|
290
|
+
onClick={() => installFromRegistry(r.package)}>
|
|
291
|
+
{busy ? <Loader2 className="h-3 w-3 animate-spin" />
|
|
292
|
+
: st === "ok" ? <><Check className="h-3 w-3" />已装</>
|
|
293
|
+
: st === "err" ? <><XCircle className="h-3 w-3 text-destructive" />失败</>
|
|
294
|
+
: <><Download className="h-3 w-3" />安装</>}
|
|
295
|
+
</Button>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
})}
|
|
300
|
+
</div>
|
|
301
|
+
</TabsContent>
|
|
302
|
+
|
|
303
|
+
{/* URL install */}
|
|
304
|
+
<TabsContent value="url" className="space-y-3 mt-3">
|
|
305
|
+
<div className="space-y-1.5">
|
|
306
|
+
<Label className="text-xs text-muted-foreground">SKILL.md 的原始 URL</Label>
|
|
307
|
+
<Input value={url} onChange={e => setUrl(e.target.value)}
|
|
308
|
+
placeholder="https://raw.githubusercontent.com/user/repo/main/skill/SKILL.md"
|
|
309
|
+
className="font-mono text-sm" />
|
|
310
|
+
<p className="text-[11px] text-muted-foreground">支持 GitHub raw 链接或任意可访问的 markdown 地址</p>
|
|
311
|
+
</div>
|
|
312
|
+
{status === "ok" && <p className="text-sm text-green-600 flex items-center gap-1"><Check className="h-4 w-4" />安装成功!</p>}
|
|
313
|
+
{status === "err" && <p className="text-sm text-destructive flex items-center gap-1"><XCircle className="h-4 w-4" />{errMsg}</p>}
|
|
314
|
+
<DialogFooter>
|
|
315
|
+
<Button variant="outline" onClick={() => { onClose(); reset(); }}>取消</Button>
|
|
316
|
+
<Button onClick={installManual} disabled={status === "loading" || !url.trim()}>
|
|
317
|
+
{status === "loading" && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}安装
|
|
318
|
+
</Button>
|
|
319
|
+
</DialogFooter>
|
|
320
|
+
</TabsContent>
|
|
321
|
+
|
|
322
|
+
{/* Paste content */}
|
|
323
|
+
<TabsContent value="paste" className="space-y-3 mt-3">
|
|
324
|
+
<div className="space-y-1.5">
|
|
325
|
+
<Label className="text-xs text-muted-foreground">Skill 名称(slug)</Label>
|
|
326
|
+
<Input value={name} onChange={e => setName(e.target.value)} placeholder="my-skill" className="font-mono text-sm" />
|
|
327
|
+
</div>
|
|
328
|
+
<div className="space-y-1.5">
|
|
329
|
+
<Label className="text-xs text-muted-foreground">SKILL.md 内容</Label>
|
|
330
|
+
<Textarea value={content} onChange={e => setContent(e.target.value)}
|
|
331
|
+
placeholder={"---\nname: my-skill\ndescription: 描述这个 skill 的用途\n---\n\n# My Skill\n\n..."}
|
|
332
|
+
rows={8} className="font-mono text-xs" />
|
|
333
|
+
</div>
|
|
334
|
+
{status === "ok" && <p className="text-sm text-green-600 flex items-center gap-1"><Check className="h-4 w-4" />安装成功!</p>}
|
|
335
|
+
{status === "err" && <p className="text-sm text-destructive flex items-center gap-1"><XCircle className="h-4 w-4" />{errMsg}</p>}
|
|
336
|
+
<DialogFooter>
|
|
337
|
+
<Button variant="outline" onClick={() => { onClose(); reset(); }}>取消</Button>
|
|
338
|
+
<Button onClick={installManual} disabled={status === "loading" || !content.trim()}>
|
|
339
|
+
{status === "loading" && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}安装
|
|
340
|
+
</Button>
|
|
341
|
+
</DialogFooter>
|
|
342
|
+
</TabsContent>
|
|
343
|
+
</Tabs>
|
|
344
|
+
</DialogContent>
|
|
345
|
+
</Dialog>
|
|
346
|
+
);
|
|
347
|
+
}
|