@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,185 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback } from "react";
4
+ import { Folder, FolderOpen, ChevronRight, ChevronUp, Home, Check } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
8
+ import { ScrollArea } from "@/components/ui/scroll-area";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ interface BrowseResult {
12
+ current: string;
13
+ display: string;
14
+ breadcrumb: { label: string; path: string }[];
15
+ entries: { name: string; path: string }[];
16
+ canGoUp: boolean;
17
+ parent: string;
18
+ }
19
+
20
+ interface DirectoryPickerProps {
21
+ value: string;
22
+ onChange: (path: string) => void;
23
+ placeholder?: string;
24
+ }
25
+
26
+ export function DirectoryPicker({ value, onChange, placeholder }: DirectoryPickerProps) {
27
+ const [open, setOpen] = useState(false);
28
+ const [result, setResult] = useState<BrowseResult | null>(null);
29
+ const [loading, setLoading] = useState(false);
30
+ const [selected, setSelected] = useState<string>("");
31
+
32
+ const browse = useCallback(async (p: string) => {
33
+ setLoading(true);
34
+ try {
35
+ const res = await fetch(`/api/fs/browse?path=${encodeURIComponent(p)}`);
36
+ const data: BrowseResult = await res.json();
37
+ setResult(data);
38
+
39
+ } catch {
40
+ // ignore
41
+ } finally {
42
+ setLoading(false);
43
+ }
44
+ }, []);
45
+
46
+ const handleOpen = () => {
47
+ const startPath = value?.trim().replace(/^~/, "~") || "~";
48
+ setSelected(value || "");
49
+ browse(startPath.replace(/^~/, "") ? startPath : "~");
50
+ setOpen(true);
51
+ };
52
+
53
+ const handleConfirm = () => {
54
+ onChange(selected || result?.current || "");
55
+ setOpen(false);
56
+ };
57
+
58
+ return (
59
+ <>
60
+ {/* 触发区:文本输入 + 选择按钮 */}
61
+ <div className="flex gap-2">
62
+ <Input
63
+ value={value}
64
+ onChange={(e) => onChange(e.target.value)}
65
+ placeholder={placeholder ?? "~/projects/my-project"}
66
+ className="font-mono text-sm flex-1"
67
+ />
68
+ <Button type="button" variant="outline" size="sm" onClick={handleOpen} className="shrink-0 gap-1.5">
69
+ <FolderOpen className="h-4 w-4" />
70
+ 浏览
71
+ </Button>
72
+ </div>
73
+
74
+ {/* 目录浏览 Dialog */}
75
+ <Dialog open={open} onOpenChange={setOpen}>
76
+ <DialogContent className="max-w-lg p-0 gap-0">
77
+ <DialogHeader className="px-4 pt-4 pb-3 border-b">
78
+ <DialogTitle className="text-base">选择工作区目录</DialogTitle>
79
+ </DialogHeader>
80
+
81
+ {/* 面包屑导航 */}
82
+ <div className="flex items-center gap-1 px-3 py-2 border-b bg-secondary/30 flex-wrap">
83
+ <button
84
+ type="button"
85
+ onClick={() => browse("~")}
86
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
87
+ >
88
+ <Home className="h-3.5 w-3.5" />
89
+ </button>
90
+ {result?.breadcrumb.slice(1).map((b, i) => (
91
+ <span key={i} className="flex items-center gap-1">
92
+ <ChevronRight className="h-3 w-3 text-muted-foreground" />
93
+ <button
94
+ type="button"
95
+ onClick={() => browse(b.path)}
96
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors max-w-[120px] truncate"
97
+ >
98
+ {b.label}
99
+ </button>
100
+ </span>
101
+ ))}
102
+ </div>
103
+
104
+ {/* 目录列表 */}
105
+ <ScrollArea className="h-64">
106
+ <div className="p-2 space-y-0.5">
107
+ {/* 返回上级 */}
108
+ {result?.canGoUp && (
109
+ <button
110
+ type="button"
111
+ onClick={() => browse(result.parent)}
112
+ className="w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary/60 transition-colors"
113
+ >
114
+ <ChevronUp className="h-4 w-4" />
115
+ <span className="text-xs">返回上级</span>
116
+ </button>
117
+ )}
118
+
119
+ {loading && !result && (
120
+ <p className="text-sm text-muted-foreground text-center py-8">加载中…</p>
121
+ )}
122
+
123
+ {!loading && result?.entries.length === 0 && (
124
+ <p className="text-sm text-muted-foreground text-center py-8">此目录为空</p>
125
+ )}
126
+
127
+ {result?.entries.map((entry) => {
128
+ const isSelected = selected === entry.path;
129
+ return (
130
+ <div key={entry.path} className="flex items-center group">
131
+ <button
132
+ type="button"
133
+ onClick={() => setSelected(entry.path)}
134
+ onDoubleClick={() => browse(entry.path)}
135
+ className={cn(
136
+ "flex-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors text-left",
137
+ isSelected
138
+ ? "bg-[#57BE6B]/15 dark:bg-[#57BE6B]/20 border border-[#57BE6B]/40"
139
+ : "hover:bg-secondary/60 border border-transparent",
140
+ )}
141
+ >
142
+ <Folder className={cn("h-4 w-4 shrink-0", isSelected ? "text-[#57BE6B]" : "text-amber-500")} />
143
+ <span className="truncate font-medium">{entry.name}</span>
144
+ {isSelected && <Check className="ml-auto h-3.5 w-3.5 text-[#57BE6B] shrink-0" />}
145
+ </button>
146
+ {/* 双击进入 / 点击箭头进入 */}
147
+ <button
148
+ type="button"
149
+ onClick={() => browse(entry.path)}
150
+ className="opacity-0 group-hover:opacity-100 p-2 text-muted-foreground hover:text-foreground transition-all"
151
+ title="进入目录"
152
+ >
153
+ <ChevronRight className="h-4 w-4" />
154
+ </button>
155
+ </div>
156
+ );
157
+ })}
158
+ </div>
159
+ </ScrollArea>
160
+
161
+ {/* 当前选中路径显示 */}
162
+ <div className="px-4 py-2.5 border-t bg-secondary/20">
163
+ <div className="flex items-center gap-2">
164
+ <span className="text-xs text-muted-foreground shrink-0">选中:</span>
165
+ <code className="text-xs font-mono text-foreground truncate flex-1">
166
+ {selected || result?.current || "未选择"}
167
+ </code>
168
+ </div>
169
+ </div>
170
+
171
+ <DialogFooter className="px-4 py-3 border-t">
172
+ <Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
173
+ <Button
174
+ onClick={handleConfirm}
175
+ disabled={!selected && !result?.current}
176
+ >
177
+ <FolderOpen className="mr-2 h-4 w-4" />
178
+ 选择此目录
179
+ </Button>
180
+ </DialogFooter>
181
+ </DialogContent>
182
+ </Dialog>
183
+ </>
184
+ );
185
+ }
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
5
+ ({ className, type, ...props }, ref) => (
6
+ <input
7
+ type={type}
8
+ className={cn(
9
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
10
+ className
11
+ )}
12
+ ref={ref}
13
+ {...props}
14
+ />
15
+ )
16
+ );
17
+ Input.displayName = "Input";
18
+ export { Input };
@@ -0,0 +1,16 @@
1
+ import * as React from "react";
2
+ import * as LabelPrimitive from "@radix-ui/react-label";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const Label = React.forwardRef<
6
+ React.ElementRef<typeof LabelPrimitive.Root>,
7
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
8
+ >(({ className, ...props }, ref) => (
9
+ <LabelPrimitive.Root
10
+ ref={ref}
11
+ className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
12
+ {...props}
13
+ />
14
+ ));
15
+ Label.displayName = LabelPrimitive.Root.displayName;
16
+ export { Label };
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const ScrollArea = React.forwardRef<
8
+ React.ElementRef<typeof ScrollAreaPrimitive.Root>,
9
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
10
+ >(({ className, children, ...props }, ref) => (
11
+ <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
12
+ <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
13
+ {children}
14
+ </ScrollAreaPrimitive.Viewport>
15
+ <ScrollBar />
16
+ <ScrollAreaPrimitive.Corner />
17
+ </ScrollAreaPrimitive.Root>
18
+ ));
19
+ ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
20
+
21
+ const ScrollBar = React.forwardRef<
22
+ React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
23
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
24
+ >(({ className, orientation = "vertical", ...props }, ref) => (
25
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
26
+ ref={ref}
27
+ orientation={orientation}
28
+ className={cn(
29
+ "flex touch-none select-none transition-colors",
30
+ orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
31
+ orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
37
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
38
+ ));
39
+ ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
40
+
41
+ export { ScrollArea, ScrollBar };
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SelectPrimitive from "@radix-ui/react-select";
5
+ import { Check, ChevronDown } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const Select = SelectPrimitive.Root;
9
+ const SelectGroup = SelectPrimitive.Group;
10
+ const SelectValue = SelectPrimitive.Value;
11
+
12
+ const SelectTrigger = React.forwardRef<
13
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
14
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
15
+ >(({ className, children, ...props }, ref) => (
16
+ <SelectPrimitive.Trigger
17
+ ref={ref}
18
+ className={cn(
19
+ "flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
20
+ className
21
+ )}
22
+ {...props}
23
+ >
24
+ {children}
25
+ <SelectPrimitive.Icon asChild>
26
+ <ChevronDown className="h-4 w-4 opacity-50" />
27
+ </SelectPrimitive.Icon>
28
+ </SelectPrimitive.Trigger>
29
+ ));
30
+ SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
31
+
32
+ const SelectContent = React.forwardRef<
33
+ React.ElementRef<typeof SelectPrimitive.Content>,
34
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
35
+ >(({ className, children, position = "popper", ...props }, ref) => (
36
+ <SelectPrimitive.Portal>
37
+ <SelectPrimitive.Content
38
+ ref={ref}
39
+ className={cn(
40
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-white dark:bg-zinc-800 text-foreground shadow-xl animate-in fade-in-0 zoom-in-95",
41
+ position === "popper" && "translate-y-1",
42
+ className
43
+ )}
44
+ position={position}
45
+ {...props}
46
+ >
47
+ <SelectPrimitive.Viewport className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
48
+ {children}
49
+ </SelectPrimitive.Viewport>
50
+ </SelectPrimitive.Content>
51
+ </SelectPrimitive.Portal>
52
+ ));
53
+ SelectContent.displayName = SelectPrimitive.Content.displayName;
54
+
55
+ const SelectItem = React.forwardRef<
56
+ React.ElementRef<typeof SelectPrimitive.Item>,
57
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
58
+ >(({ className, children, ...props }, ref) => (
59
+ <SelectPrimitive.Item
60
+ ref={ref}
61
+ className={cn(
62
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
63
+ className
64
+ )}
65
+ {...props}
66
+ >
67
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
68
+ <SelectPrimitive.ItemIndicator>
69
+ <Check className="h-4 w-4" />
70
+ </SelectPrimitive.ItemIndicator>
71
+ </span>
72
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
73
+ </SelectPrimitive.Item>
74
+ ));
75
+ SelectItem.displayName = SelectPrimitive.Item.displayName;
76
+
77
+ const SelectLabel = React.forwardRef<
78
+ React.ElementRef<typeof SelectPrimitive.Label>,
79
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
80
+ >(({ className, ...props }, ref) => (
81
+ <SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-xs font-semibold", className)} {...props} />
82
+ ));
83
+ SelectLabel.displayName = SelectPrimitive.Label.displayName;
84
+
85
+ const SelectSeparator = React.forwardRef<
86
+ React.ElementRef<typeof SelectPrimitive.Separator>,
87
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
88
+ >(({ className, ...props }, ref) => (
89
+ <SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
90
+ ));
91
+ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
92
+
93
+ export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem, SelectLabel, SelectSeparator };
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SliderPrimitive from "@radix-ui/react-slider";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const Slider = React.forwardRef<
8
+ React.ElementRef<typeof SliderPrimitive.Root>,
9
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
10
+ >(({ className, ...props }, ref) => (
11
+ <SliderPrimitive.Root
12
+ ref={ref}
13
+ className={cn("relative flex w-full touch-none select-none items-center", className)}
14
+ {...props}
15
+ >
16
+ {/* 轨道:明显的灰色底色,与开关 OFF 状态一致 */}
17
+ <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-600">
18
+ {/* 已填充区域:微信绿,与开关 ON 状态一致 */}
19
+ <SliderPrimitive.Range className="absolute h-full bg-[#57BE6B]" />
20
+ </SliderPrimitive.Track>
21
+
22
+ {/* 拇指:白色圆形 + 绿色描边,比较易辨 */}
23
+ <SliderPrimitive.Thumb
24
+ className={cn(
25
+ "block h-4 w-4 rounded-full bg-white shadow-md ring-0 transition-all",
26
+ "border-2 border-[#57BE6B]",
27
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#57BE6B] focus-visible:ring-offset-2",
28
+ "hover:scale-110",
29
+ "disabled:pointer-events-none disabled:opacity-50",
30
+ )}
31
+ />
32
+ </SliderPrimitive.Root>
33
+ ));
34
+ Slider.displayName = SliderPrimitive.Root.displayName;
35
+ export { Slider };
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SwitchPrimitives from "@radix-ui/react-switch";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const Switch = React.forwardRef<
8
+ React.ElementRef<typeof SwitchPrimitives.Root>,
9
+ React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
10
+ >(({ className, ...props }, ref) => (
11
+ <SwitchPrimitives.Root
12
+ className={cn(
13
+ // OFF: visible gray | ON: WeChat green — consistent with chat bubbles
14
+ "peer inline-flex h-[22px] w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
15
+ "transition-colors duration-200",
16
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
17
+ "disabled:cursor-not-allowed disabled:opacity-50",
18
+ "data-[state=unchecked]:bg-zinc-300 dark:data-[state=unchecked]:bg-zinc-600",
19
+ "data-[state=checked]:bg-[#57BE6B]",
20
+ className,
21
+ )}
22
+ {...props}
23
+ ref={ref}
24
+ >
25
+ <SwitchPrimitives.Thumb
26
+ className={cn(
27
+ "pointer-events-none block h-[16px] w-[16px] rounded-full bg-white shadow-md ring-0",
28
+ "transition-transform duration-200",
29
+ "data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0",
30
+ )}
31
+ />
32
+ </SwitchPrimitives.Root>
33
+ ));
34
+ Switch.displayName = SwitchPrimitives.Root.displayName;
35
+ export { Switch };
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as TabsPrimitive from "@radix-ui/react-tabs";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const Tabs = TabsPrimitive.Root;
8
+
9
+ const TabsList = React.forwardRef<
10
+ React.ElementRef<typeof TabsPrimitive.List>,
11
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
12
+ >(({ className, ...props }, ref) => (
13
+ <TabsPrimitive.List
14
+ ref={ref}
15
+ className={cn("inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className)}
16
+ {...props}
17
+ />
18
+ ));
19
+ TabsList.displayName = TabsPrimitive.List.displayName;
20
+
21
+ const TabsTrigger = React.forwardRef<
22
+ React.ElementRef<typeof TabsPrimitive.Trigger>,
23
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
24
+ >(({ className, ...props }, ref) => (
25
+ <TabsPrimitive.Trigger
26
+ ref={ref}
27
+ className={cn(
28
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
29
+ className
30
+ )}
31
+ {...props}
32
+ />
33
+ ));
34
+ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
35
+
36
+ const TabsContent = React.forwardRef<
37
+ React.ElementRef<typeof TabsPrimitive.Content>,
38
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
39
+ >(({ className, ...props }, ref) => (
40
+ <TabsPrimitive.Content
41
+ ref={ref}
42
+ className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
43
+ {...props}
44
+ />
45
+ ));
46
+ TabsContent.displayName = TabsPrimitive.Content.displayName;
47
+
48
+ export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,17 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
5
+ ({ className, ...props }, ref) => (
6
+ <textarea
7
+ className={cn(
8
+ "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
9
+ className
10
+ )}
11
+ ref={ref}
12
+ {...props}
13
+ />
14
+ )
15
+ );
16
+ Textarea.displayName = "Textarea";
17
+ export { Textarea };
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { useToastState, dismissToast } from "@/hooks/use-toast";
3
+ import { cn } from "@/lib/utils";
4
+ import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
5
+
6
+ export function Toaster() {
7
+ const toasts = useToastState();
8
+ if (!toasts.length) return null;
9
+
10
+ return (
11
+ <div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
12
+ {toasts.map((t) => (
13
+ <div
14
+ key={t.id}
15
+ className={cn(
16
+ "pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-lg",
17
+ t.variant === "destructive"
18
+ ? "bg-red-50 border-red-200 dark:bg-red-900/30 dark:border-red-800"
19
+ : t.variant === "success"
20
+ ? "bg-green-50 border-green-200 dark:bg-green-900/30 dark:border-green-800"
21
+ : "bg-white border-border dark:bg-zinc-800",
22
+ )}
23
+ >
24
+ {t.variant === "destructive" && (
25
+ <AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
26
+ )}
27
+ {t.variant === "success" && (
28
+ <CheckCircle className="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
29
+ )}
30
+ {(!t.variant || t.variant === "default") && (
31
+ <Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
32
+ )}
33
+ <div className="flex-1 min-w-0">
34
+ <p className="text-sm font-medium leading-none">{t.title}</p>
35
+ {t.description && (
36
+ <p className="text-xs text-muted-foreground mt-1">{t.description}</p>
37
+ )}
38
+ </div>
39
+ <button
40
+ type="button"
41
+ onClick={() => dismissToast(t.id)}
42
+ className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
43
+ >
44
+ <X className="h-3.5 w-3.5" />
45
+ </button>
46
+ </div>
47
+ ))}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { useState, useEffect } from "react";
3
+
4
+ export type ToastVariant = "default" | "destructive" | "success";
5
+
6
+ export interface Toast {
7
+ id: string;
8
+ title: string;
9
+ description?: string;
10
+ variant?: ToastVariant;
11
+ }
12
+
13
+ // Module-level store — callable outside React components
14
+ let _listeners: Array<(t: Toast[]) => void> = [];
15
+ let _toasts: Toast[] = [];
16
+ const DURATION = 4000;
17
+
18
+ function _notify(next: Toast[]) {
19
+ _toasts = next;
20
+ _listeners.forEach((fn) => fn(_toasts));
21
+ }
22
+
23
+ export function toast(props: Omit<Toast, "id">) {
24
+ const id = Math.random().toString(36).slice(2);
25
+ _notify([..._toasts, { ...props, id }]);
26
+ setTimeout(() => _notify(_toasts.filter((t) => t.id !== id)), DURATION);
27
+ return id;
28
+ }
29
+
30
+ export function dismissToast(id: string) {
31
+ _notify(_toasts.filter((t) => t.id !== id));
32
+ }
33
+
34
+ export function useToastState() {
35
+ const [toasts, setToasts] = useState<Toast[]>(_toasts);
36
+ useEffect(() => {
37
+ _listeners.push(setToasts);
38
+ return () => {
39
+ _listeners = _listeners.filter((fn) => fn !== setToasts);
40
+ };
41
+ }, []);
42
+ return toasts;
43
+ }
@@ -0,0 +1,13 @@
1
+ import { getRequestConfig } from "next-intl/server";
2
+ import { routing } from "./routing";
3
+
4
+ export default getRequestConfig(async ({ requestLocale }) => {
5
+ let locale = await requestLocale;
6
+ if (!locale || !routing.locales.includes(locale as "zh" | "en")) {
7
+ locale = routing.defaultLocale;
8
+ }
9
+ return {
10
+ locale,
11
+ messages: (await import(`../../messages/${locale}.json`)).default,
12
+ };
13
+ });
@@ -0,0 +1,7 @@
1
+ import { defineRouting } from "next-intl/routing";
2
+
3
+ export const routing = defineRouting({
4
+ locales: ["zh", "en"],
5
+ defaultLocale: "zh",
6
+ localePrefix: "always",
7
+ });