@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,58 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useLocale, useTranslations } from "next-intl";
6
+ import { ChevronLeft } from "lucide-react";
7
+ import { AgentForm } from "@/components/agents/agent-form";
8
+ import { Button } from "@/components/ui/button";
9
+ import type { DigitalAgent, CreateAgentInput } from "@/types/agent";
10
+
11
+ export default function EditAgentPage({ params }: { params: Promise<{ id: string }> }) {
12
+ const t = useTranslations("agents");
13
+ const tc = useTranslations("common");
14
+ const locale = useLocale();
15
+ const router = useRouter();
16
+ const [agent, setAgent] = useState<DigitalAgent | null>(null);
17
+ const [saving, setSaving] = useState(false);
18
+
19
+ useEffect(() => {
20
+ params.then(({ id }) =>
21
+ fetch(`/api/agents/${id}`).then((r) => r.json()).then(setAgent)
22
+ );
23
+ }, [params]);
24
+
25
+ const handleSave = async (data: CreateAgentInput) => {
26
+ if (!agent) return;
27
+ setSaving(true);
28
+ try {
29
+ await fetch(`/api/agents/${agent.id}`, {
30
+ method: "PUT",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify(data),
33
+ });
34
+ router.push(`/${locale}/agents`);
35
+ } finally {
36
+ setSaving(false);
37
+ }
38
+ };
39
+
40
+ if (!agent) return <div className="flex items-center justify-center h-full text-gray-600 dark:text-muted-foreground">{tc("loading")}</div>;
41
+
42
+ return (
43
+ <div className="p-6 max-w-2xl mx-auto min-h-full bg-gradient-to-br from-gray-50 to-gray-100 dark:from-slate-900 dark:to-slate-900">
44
+ <div className="mb-6 flex items-center gap-3">
45
+ <Button variant="ghost" size="icon" onClick={() => router.back()} className="neo-button hover:scale-110 transition-transform">
46
+ <ChevronLeft className="h-5 w-5" />
47
+ </Button>
48
+ <h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t("editConfig")}</h1>
49
+ </div>
50
+ <AgentForm
51
+ initialValues={agent}
52
+ onSave={handleSave}
53
+ saving={saving}
54
+ submitLabel={tc("save")}
55
+ />
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useLocale, useTranslations } from "next-intl";
6
+ import { ChevronLeft } from "lucide-react";
7
+ import { AgentForm } from "@/components/agents/agent-form";
8
+ import { Button } from "@/components/ui/button";
9
+ import { DEFAULT_AGENT } from "@/types/agent";
10
+ import type { CreateAgentInput } from "@/types/agent";
11
+
12
+ export default function NewAgentPage() {
13
+ const t = useTranslations("agents");
14
+ const tc = useTranslations("common");
15
+ const locale = useLocale();
16
+ const router = useRouter();
17
+ const [saving, setSaving] = useState(false);
18
+
19
+ const handleSave = async (data: CreateAgentInput) => {
20
+ setSaving(true);
21
+ try {
22
+ const res = await fetch("/api/agents", {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify(data),
26
+ });
27
+ const agent = await res.json();
28
+ router.push(`/${locale}/agents/${agent.id}/chat`);
29
+ } finally {
30
+ setSaving(false);
31
+ }
32
+ };
33
+
34
+ return (
35
+ <div className="p-6 max-w-2xl mx-auto min-h-full bg-gradient-to-br from-gray-50 to-gray-100 dark:from-slate-900 dark:to-slate-900">
36
+ <div className="mb-6 flex items-center gap-3">
37
+ <Button variant="ghost" size="icon" onClick={() => router.back()} className="neo-button hover:scale-110 transition-transform">
38
+ <ChevronLeft className="h-5 w-5" />
39
+ </Button>
40
+ <div>
41
+ <h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t("new")}</h1>
42
+ </div>
43
+ </div>
44
+ <AgentForm
45
+ initialValues={DEFAULT_AGENT}
46
+ onSave={handleSave}
47
+ saving={saving}
48
+ submitLabel={tc("create")}
49
+ />
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import Link from "next/link";
5
+ import { useLocale } from "next-intl";
6
+ import { useEffect, useState } from "react";
7
+ import { Bot, MessageSquare, Settings, Plus, Trash2 } from "lucide-react";
8
+ import type { DigitalAgent } from "@/types/agent";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Card, CardContent, CardFooter } from "@/components/ui/card";
11
+ import {
12
+ Dialog, DialogContent, DialogFooter as DFooter,
13
+ DialogHeader, DialogTitle, DialogDescription,
14
+ } from "@/components/ui/dialog";
15
+
16
+ export default function AgentsPage() {
17
+ const t = useTranslations("agents");
18
+ const tc = useTranslations("common");
19
+ const locale = useLocale();
20
+ const [agents, setAgents] = useState<DigitalAgent[]>([]);
21
+ const [deleteTarget, setDeleteTarget] = useState<DigitalAgent | null>(null);
22
+
23
+ useEffect(() => {
24
+ fetch("/api/agents").then((r) => r.json()).then(setAgents);
25
+ }, []);
26
+
27
+ const handleDelete = async () => {
28
+ if (!deleteTarget) return;
29
+ await fetch(`/api/agents/${deleteTarget.id}`, { method: "DELETE" });
30
+ setAgents((prev) => prev.filter((a) => a.id !== deleteTarget.id));
31
+ setDeleteTarget(null);
32
+ };
33
+
34
+ return (
35
+ <div className="p-6">
36
+ <div className="mb-6 flex items-center justify-between">
37
+ <div>
38
+ <h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t("title")}</h1>
39
+ <p className="text-sm text-gray-600 dark:text-muted-foreground mt-1">{t("subtitle")}</p>
40
+ </div>
41
+ <Link href={`/${locale}/agents/new`}>
42
+ <Button 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 shadow-lg">
43
+ <Plus className="mr-2 h-4 w-4" />
44
+ {t("new")}
45
+ </Button>
46
+ </Link>
47
+ </div>
48
+
49
+ {agents.length === 0 ? (
50
+ <div className="flex flex-col items-center justify-center py-24 text-center">
51
+ <div className="neo-card w-20 h-20 rounded-2xl bg-white dark:bg-secondary flex items-center justify-center mb-4">
52
+ <Bot className="h-10 w-10 text-gray-400 dark:text-muted-foreground" />
53
+ </div>
54
+ <h3 className="text-lg font-medium text-gray-800 dark:text-white">{t("empty")}</h3>
55
+ <p className="mt-2 text-sm text-gray-600 dark:text-muted-foreground max-w-sm">{t("emptyHint")}</p>
56
+ <Link href={`/${locale}/agents/new`} className="mt-4">
57
+ <Button variant="outline" className="neo-button text-gray-800 dark:text-white hover:text-blue-600 dark:hover:text-cyan-400">
58
+ <Plus className="mr-2 h-4 w-4" />{t("new")}
59
+ </Button>
60
+ </Link>
61
+ </div>
62
+ ) : (
63
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
64
+ {agents.map((agent) => (
65
+ <Card key={agent.id} className="group relative border transition-all hover:border-zinc-300 dark:hover:border-zinc-600 hover:shadow-lg hover:-translate-y-0.5 cursor-default">
66
+ <CardContent className="pt-6">
67
+ <div className="flex items-start gap-3">
68
+ <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-secondary text-2xl flex-shrink-0 group-hover:scale-105 transition-transform">
69
+ {agent.avatar}
70
+ </div>
71
+ <div className="min-w-0">
72
+ <h3 className="font-semibold truncate">{agent.name}</h3>
73
+ <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{agent.description || "—"}</p>
74
+ <p className="text-xs text-muted-foreground mt-2 font-mono">
75
+ {agent.model.provider}/{agent.model.modelId}
76
+ </p>
77
+ </div>
78
+ </div>
79
+ </CardContent>
80
+ <CardFooter className="gap-2 pt-0">
81
+ <Link href={`/${locale}/agents/${agent.id}/chat`} className="flex-1">
82
+ <Button size="sm" className="w-full">
83
+ <MessageSquare className="mr-1 h-3 w-3" />{t("chat")}
84
+ </Button>
85
+ </Link>
86
+ <Link href={`/${locale}/agents/${agent.id}/edit`}>
87
+ <Button size="sm" variant="outline">
88
+ <Settings className="h-3 w-3" />
89
+ </Button>
90
+ </Link>
91
+ <Button
92
+ size="sm"
93
+ variant="ghost"
94
+ className="text-destructive hover:text-destructive"
95
+ onClick={() => setDeleteTarget(agent)}
96
+ >
97
+ <Trash2 className="h-3 w-3" />
98
+ </Button>
99
+ </CardFooter>
100
+ </Card>
101
+ ))}
102
+ </div>
103
+ )}
104
+
105
+ {/* Delete confirm dialog */}
106
+ <Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
107
+ <DialogContent>
108
+ <DialogHeader>
109
+ <DialogTitle>{tc("confirmDelete")}</DialogTitle>
110
+ <DialogDescription>
111
+ {t("deleteConfirm", { name: deleteTarget?.name ?? "" })}
112
+ </DialogDescription>
113
+ </DialogHeader>
114
+ <DFooter>
115
+ <Button variant="outline" onClick={() => setDeleteTarget(null)}>{tc("cancel")}</Button>
116
+ <Button variant="destructive" onClick={handleDelete}>{tc("delete")}</Button>
117
+ </DFooter>
118
+ </DialogContent>
119
+ </Dialog>
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,305 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { useTranslations } from "next-intl";
5
+ import { Plus, Upload, Trash2, Search, BookOpen, FileText } from "lucide-react";
6
+ import type { KnowledgeBase, KbDocument } from "@/types/knowledge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Label } from "@/components/ui/label";
10
+ import { Textarea } from "@/components/ui/textarea";
11
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
12
+ import { ScrollArea } from "@/components/ui/scroll-area";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ export default function KnowledgePage() {
16
+ const t = useTranslations("knowledge");
17
+ const tc = useTranslations("common");
18
+
19
+ const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
20
+ const [selected, setSelected] = useState<KnowledgeBase | null>(null);
21
+ const [docs, setDocs] = useState<KbDocument[]>([]);
22
+ const [showNewKb, setShowNewKb] = useState(false);
23
+ const [newKbName, setNewKbName] = useState("");
24
+ const [newKbDesc, setNewKbDesc] = useState("");
25
+ const [uploading, setUploading] = useState(false);
26
+ const [searchQuery, setSearchQuery] = useState("");
27
+ const [searchResults, setSearchResults] = useState<{ content: string; score: number; filename: string }[]>([]);
28
+ const [searching, setSearching] = useState(false);
29
+ const fileInputRef = useRef<HTMLInputElement>(null);
30
+ const pollRef = useRef<NodeJS.Timeout | null>(null);
31
+
32
+ useEffect(() => {
33
+ fetch("/api/knowledge").then((r) => r.json()).then(setKbs);
34
+ }, []);
35
+
36
+ useEffect(() => {
37
+ if (!selected) return;
38
+ const loadDocs = () =>
39
+ fetch(`/api/knowledge/${selected.id}`)
40
+ .then((r) => r.json())
41
+ .then((d: KnowledgeBase & { documents: KbDocument[] }) => {
42
+ setDocs(d.documents);
43
+ if (d.documents.some((doc) => doc.status === "processing")) {
44
+ pollRef.current = setTimeout(loadDocs, 2000);
45
+ }
46
+ });
47
+ loadDocs();
48
+ return () => { if (pollRef.current) clearTimeout(pollRef.current); };
49
+ }, [selected]);
50
+
51
+ const createKb = async () => {
52
+ const res = await fetch("/api/knowledge", {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify({ name: newKbName, description: newKbDesc }),
56
+ });
57
+ const kb = await res.json();
58
+ setKbs((prev) => [kb, ...prev]);
59
+ setNewKbName("");
60
+ setNewKbDesc("");
61
+ setShowNewKb(false);
62
+ setSelected(kb);
63
+ };
64
+
65
+ const deleteKb = async (id: string) => {
66
+ await fetch(`/api/knowledge/${id}`, { method: "DELETE" });
67
+ setKbs((prev) => prev.filter((k) => k.id !== id));
68
+ if (selected?.id === id) setSelected(null);
69
+ };
70
+
71
+ const uploadFile = async (file: File) => {
72
+ if (!selected) return;
73
+ setUploading(true);
74
+ const fd = new FormData();
75
+ fd.append("file", file);
76
+ await fetch(`/api/knowledge/${selected.id}/upload`, { method: "POST", body: fd });
77
+ setUploading(false);
78
+ // refresh docs
79
+ fetch(`/api/knowledge/${selected.id}`)
80
+ .then((r) => r.json())
81
+ .then((d: KnowledgeBase & { documents: KbDocument[] }) => setDocs(d.documents));
82
+ };
83
+
84
+ const deleteDoc = async (docId: string) => {
85
+ await fetch(`/api/knowledge/${selected!.id}/documents/${docId}`, { method: "DELETE" });
86
+ setDocs((prev) => prev.filter((d) => d.id !== docId));
87
+ };
88
+
89
+ const handleSearch = async () => {
90
+ if (!selected || !searchQuery.trim()) return;
91
+ setSearching(true);
92
+ const res = await fetch("/api/knowledge/search", {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json" },
95
+ body: JSON.stringify({ kbIds: [selected.id], query: searchQuery }),
96
+ });
97
+ const data = await res.json();
98
+ setSearchResults(data);
99
+ setSearching(false);
100
+ };
101
+
102
+ return (
103
+ <div className="flex h-full bg-white dark:bg-slate-900">
104
+ {/* KB list sidebar */}
105
+ <div className="w-64 flex-shrink-0 border-r border-gray-200 dark:border-slate-700/50 flex flex-col bg-[hsl(var(--neo-bg))] dark:bg-slate-900/95">
106
+ <div className="p-3 border-b border-gray-200 dark:border-slate-700/50 flex items-center justify-between">
107
+ <h2 className="font-semibold text-sm text-gray-800 dark:text-white">{t("title")}</h2>
108
+ <Button size="icon" variant="ghost" className="h-7 w-7 neo-button hover:scale-110 transition-transform" onClick={() => setShowNewKb(true)}>
109
+ <Plus className="h-4 w-4" />
110
+ </Button>
111
+ </div>
112
+ <ScrollArea className="flex-1">
113
+ <div className="p-2 space-y-1">
114
+ {kbs.length === 0 ? (
115
+ <div className="py-8 text-center">
116
+ <div className="neo-card w-12 h-12 mx-auto rounded-xl bg-white dark:bg-slate-800/50 flex items-center justify-center mb-2">
117
+ <BookOpen className="h-6 w-6 text-gray-400 dark:text-muted-foreground" />
118
+ </div>
119
+ <p className="text-xs text-gray-600 dark:text-muted-foreground">{t("empty")}</p>
120
+ </div>
121
+ ) : (
122
+ kbs.map((kb) => (
123
+ <div
124
+ key={kb.id}
125
+ className={cn(
126
+ "group relative flex items-center justify-between rounded-lg px-3 py-2.5 cursor-pointer transition-all",
127
+ selected?.id === kb.id
128
+ ? "neo-card-inset bg-blue-50 dark:bg-[#57BE6B]/20 border border-blue-200 dark:border-[#57BE6B]/60 shadow-sm"
129
+ : "neo-button border border-transparent hover:border-gray-300 dark:hover:border-zinc-700 hover:scale-[1.02]",
130
+ )}
131
+ onClick={() => setSelected(kb)}
132
+ >
133
+ {selected?.id === kb.id && (
134
+ <span className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-blue-500 dark:bg-[#57BE6B]" />
135
+ )}
136
+ <div className="min-w-0">
137
+ <p className="text-sm font-medium truncate text-gray-800 dark:text-white">{kb.name}</p>
138
+ <p className="text-xs text-gray-600 dark:text-muted-foreground">
139
+ {kb.documentCount} {t("documents")}
140
+ </p>
141
+ </div>
142
+ <Button
143
+ size="icon"
144
+ variant="ghost"
145
+ className="h-6 w-6 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive flex-shrink-0 hover:scale-110 transition-all"
146
+ onClick={(e) => { e.stopPropagation(); deleteKb(kb.id); }}
147
+ >
148
+ <Trash2 className="h-3 w-3" />
149
+ </Button>
150
+ </div>
151
+ ))
152
+ )}
153
+ </div>
154
+ </ScrollArea>
155
+ </div>
156
+
157
+ {/* Main area */}
158
+ <div className="flex-1 min-w-0 overflow-auto bg-gradient-to-br from-gray-50 to-gray-100 dark:from-slate-900 dark:via-slate-900 dark:to-slate-900">
159
+ {!selected ? (
160
+ <div className="flex h-full items-center justify-center text-center">
161
+ <div>
162
+ <div className="neo-card w-20 h-20 mx-auto rounded-2xl bg-white dark:bg-slate-800/50 flex items-center justify-center mb-3">
163
+ <BookOpen className="h-10 w-10 text-gray-400 dark:text-muted-foreground" />
164
+ </div>
165
+ <p className="text-gray-600 dark:text-muted-foreground">{t("emptyHint")}</p>
166
+ </div>
167
+ </div>
168
+ ) : (
169
+ <div className="p-6 space-y-6 max-w-3xl">
170
+ <div className="flex items-center justify-between">
171
+ <div>
172
+ <h2 className="text-xl font-bold text-gray-800 dark:text-white">{selected.name}</h2>
173
+ <p className="text-sm text-gray-600 dark:text-muted-foreground">
174
+ {selected.documentCount} {t("documents")} · {selected.chunkCount} {t("chunks")}
175
+ </p>
176
+ </div>
177
+ <div>
178
+ <input
179
+ ref={fileInputRef}
180
+ type="file"
181
+ accept=".pdf,.docx,.txt,.md"
182
+ className="hidden"
183
+ onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f); }}
184
+ />
185
+ <Button
186
+ variant="outline"
187
+ disabled={uploading}
188
+ onClick={() => fileInputRef.current?.click()}
189
+ 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 border-0"
190
+ >
191
+ <Upload className="mr-2 h-4 w-4" />
192
+ {uploading ? tc("loading") : t("uploadDoc")}
193
+ </Button>
194
+ </div>
195
+ </div>
196
+
197
+ {/* Documents */}
198
+ <div className="space-y-2">
199
+ <Label className="text-gray-700 dark:text-gray-300">{t("documents")}</Label>
200
+ {docs.length === 0 ? (
201
+ <div className="neo-card rounded-lg border-2 border-dashed border-gray-300 dark:border-slate-700 p-8 text-center bg-white dark:bg-slate-800/30">
202
+ <div className="neo-card w-12 h-12 mx-auto rounded-xl bg-white dark:bg-slate-800/50 flex items-center justify-center mb-2">
203
+ <FileText className="h-6 w-6 text-gray-400 dark:text-muted-foreground" />
204
+ </div>
205
+ <p className="text-sm text-gray-600 dark:text-muted-foreground">{t("uploadHint")}</p>
206
+ </div>
207
+ ) : (
208
+ docs.map((doc) => (
209
+ <div key={doc.id} className="neo-card flex items-center gap-3 rounded-lg px-4 py-2.5 bg-white dark:bg-slate-800/50 hover:scale-[1.01] transition-transform">
210
+ <FileText className="h-4 w-4 text-blue-500 dark:text-cyan-400 flex-shrink-0" />
211
+ <div className="flex-1 min-w-0">
212
+ <p className="text-sm font-medium truncate text-gray-800 dark:text-white">{doc.filename}</p>
213
+ <p className="text-xs text-gray-600 dark:text-muted-foreground">
214
+ {doc.status === "processing" ? (
215
+ <span className="animate-pulse">{t("processing")}</span>
216
+ ) : (
217
+ `${doc.chunkCount} chunks`
218
+ )}
219
+ </p>
220
+ </div>
221
+ <Button
222
+ size="icon"
223
+ variant="ghost"
224
+ className="h-7 w-7 text-destructive hover:text-destructive hover:scale-110 transition-transform"
225
+ onClick={() => deleteDoc(doc.id)}
226
+ >
227
+ <Trash2 className="h-3 w-3" />
228
+ </Button>
229
+ </div>
230
+ ))
231
+ )}
232
+ </div>
233
+
234
+ {/* Search test */}
235
+ <div className="space-y-3">
236
+ <Label className="text-gray-700 dark:text-gray-300">{t("searchTest")}</Label>
237
+ <div className="flex gap-2">
238
+ <Input
239
+ value={searchQuery}
240
+ onChange={(e) => setSearchQuery(e.target.value)}
241
+ placeholder={t("searchPlaceholder")}
242
+ onKeyDown={(e) => e.key === "Enter" && handleSearch()}
243
+ className="neo-card bg-white dark:bg-slate-800/50"
244
+ />
245
+ <Button variant="outline" onClick={handleSearch} disabled={searching} className="neo-button hover:scale-105 transition-transform">
246
+ <Search className="h-4 w-4" />
247
+ </Button>
248
+ </div>
249
+ {searchResults.length > 0 && (
250
+ <div className="space-y-2">
251
+ {searchResults.map((r, i) => (
252
+ <div key={i} className="neo-card rounded-lg p-3 text-sm bg-white dark:bg-slate-800/50">
253
+ <div className="flex items-center justify-between mb-1">
254
+ <span className="text-xs text-gray-600 dark:text-muted-foreground">{r.filename}</span>
255
+ <span className="text-xs text-blue-600 dark:text-cyan-400 font-medium">
256
+ {t("relevance")}: {(r.score * 100).toFixed(1)}%
257
+ </span>
258
+ </div>
259
+ <p className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4">{r.content}</p>
260
+ </div>
261
+ ))}
262
+ </div>
263
+ )}
264
+ {searchResults.length === 0 && searchQuery && !searching && (
265
+ <p className="text-sm text-gray-600 dark:text-muted-foreground">{t("noResults")}</p>
266
+ )}
267
+ </div>
268
+ </div>
269
+ )}
270
+ </div>
271
+
272
+ {/* New KB dialog */}
273
+ <Dialog open={showNewKb} onOpenChange={setShowNewKb}>
274
+ <DialogContent>
275
+ <DialogHeader>
276
+ <DialogTitle>{t("new")}</DialogTitle>
277
+ </DialogHeader>
278
+ <div className="space-y-3">
279
+ <div className="space-y-1.5">
280
+ <Label>{t("form.name")}</Label>
281
+ <Input
282
+ value={newKbName}
283
+ onChange={(e) => setNewKbName(e.target.value)}
284
+ placeholder={t("form.namePlaceholder")}
285
+ />
286
+ </div>
287
+ <div className="space-y-1.5">
288
+ <Label>{tc("description")}</Label>
289
+ <Textarea
290
+ value={newKbDesc}
291
+ onChange={(e) => setNewKbDesc(e.target.value)}
292
+ placeholder={t("form.descriptionPlaceholder")}
293
+ rows={3}
294
+ />
295
+ </div>
296
+ </div>
297
+ <DialogFooter>
298
+ <Button variant="outline" onClick={() => setShowNewKb(false)} className="neo-button">{tc("cancel")}</Button>
299
+ <Button onClick={createKb} disabled={!newKbName.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">{tc("create")}</Button>
300
+ </DialogFooter>
301
+ </DialogContent>
302
+ </Dialog>
303
+ </div>
304
+ );
305
+ }
@@ -0,0 +1,46 @@
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import { NextIntlClientProvider } from "next-intl";
4
+ import { getMessages } from "next-intl/server";
5
+ import { notFound } from "next/navigation";
6
+ import { routing } from "@/i18n/routing";
7
+ import { Providers } from "@/components/providers";
8
+ import { Sidebar } from "@/components/layout/sidebar";
9
+ import "@/app/globals.css";
10
+
11
+ const inter = Inter({ subsets: ["latin"] });
12
+
13
+ export const metadata: Metadata = {
14
+ title: "Digital Agent Platform",
15
+ description: "Configure and chat with your AI digital agents",
16
+ };
17
+
18
+ export default async function LocaleLayout({
19
+ children,
20
+ params,
21
+ }: {
22
+ children: React.ReactNode;
23
+ params: Promise<{ locale: string }>;
24
+ }) {
25
+ const { locale } = await params;
26
+ if (!routing.locales.includes(locale as "zh" | "en")) notFound();
27
+
28
+ const messages = await getMessages();
29
+
30
+ return (
31
+ <html lang={locale} suppressHydrationWarning>
32
+ <body className={inter.className}>
33
+ <Providers>
34
+ <NextIntlClientProvider messages={messages}>
35
+ <div className="flex h-screen overflow-hidden">
36
+ <Sidebar />
37
+ <main className="flex-1 overflow-auto">
38
+ {children}
39
+ </main>
40
+ </div>
41
+ </NextIntlClientProvider>
42
+ </Providers>
43
+ </body>
44
+ </html>
45
+ );
46
+ }
@@ -0,0 +1,6 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
4
+ const { locale } = await params;
5
+ redirect(`/${locale}/agents`);
6
+ }