@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,561 @@
1
+ "use client";
2
+
3
+ import { useRef, useEffect, useState, useCallback } from "react";
4
+ import { useTranslations } from "next-intl";
5
+ import {
6
+ Mic, MicOff, VolumeX, Zap, Terminal, Plus, Trash2,
7
+ ChevronDown, ImagePlus, X, Send, Cpu,
8
+ } from "lucide-react";
9
+ import { Button } from "@/components/ui/button";
10
+ import { cn } from "@/lib/utils";
11
+ import type { SkillInfo } from "@/lib/skills";
12
+ import type { AttachedImage } from "@/types/agent";
13
+ import type { ProviderGroup } from "@/app/api/models/route";
14
+
15
+ interface ISpeechRecognition {
16
+ lang: string; continuous: boolean; interimResults: boolean;
17
+ onresult: ((e: { results: { 0: { 0: { transcript: string } } } }) => void) | null;
18
+ onend: (() => void) | null; onerror: (() => void) | null;
19
+ start(): void; stop(): void;
20
+ }
21
+ interface ISpeechRecognitionCtor { new (): ISpeechRecognition }
22
+
23
+ export type SendMode = "normal" | "steer" | "followup";
24
+
25
+ interface SlashCommand {
26
+ id: string; label: string; description: string;
27
+ icon: React.ReactNode; category: "system" | "skill";
28
+ insert?: string; action?: () => void;
29
+ }
30
+
31
+ interface InputBarProps {
32
+ onSend: (text: string, mode: SendMode, images?: AttachedImage[]) => void;
33
+ disabled?: boolean;
34
+ voiceEnabled?: boolean;
35
+ voiceSpeed?: number;
36
+ agentLanguage?: string;
37
+ ttsProvider?: "browser" | "openai";
38
+ ttsModel?: string;
39
+ voiceId?: string;
40
+ pendingReply?: string;
41
+ onSpeakDone?: () => void;
42
+ installedSkills?: SkillInfo[];
43
+ onNewSession?: () => void;
44
+ onClearMessages?: () => void;
45
+ isStreaming?: boolean;
46
+ currentModel?: { provider: string; modelId: string };
47
+ onModelChange?: (provider: string, modelId: string) => void;
48
+ }
49
+
50
+ const MODE_META: Record<SendMode, { label: string; color: string; desc: string; activeColor: string }> = {
51
+ normal: { label: "发送", color: "bg-blue-500 hover:bg-blue-600 dark:bg-cyan-600 dark:hover:bg-cyan-500", activeColor: "text-blue-600 dark:text-cyan-400", desc: "普通发送" },
52
+ steer: { label: "引导", color: "bg-orange-500 hover:bg-orange-600 dark:bg-orange-600 dark:hover:bg-orange-500", activeColor: "text-orange-600 dark:text-orange-400", desc: "中断并引导当前生成" },
53
+ followup: { label: "追加", color: "bg-indigo-500 hover:bg-indigo-600 dark:bg-purple-600 dark:hover:bg-purple-500", activeColor: "text-indigo-600 dark:text-purple-400", desc: "完成后追加" },
54
+ };
55
+
56
+ const MAX_IMAGES = 4;
57
+ const ACCEPT_TYPES = "image/jpeg,image/png,image/gif,image/webp";
58
+
59
+ async function fileToAttachedImage(file: File): Promise<AttachedImage> {
60
+ return new Promise((resolve, reject) => {
61
+ const reader = new FileReader();
62
+ reader.onload = () => {
63
+ const dataUrl = reader.result as string;
64
+ const base64 = dataUrl.split(",")[1];
65
+ resolve({
66
+ id: Math.random().toString(36).slice(2),
67
+ data: base64,
68
+ mimeType: file.type || "image/jpeg",
69
+ name: file.name,
70
+ previewUrl: dataUrl,
71
+ });
72
+ };
73
+ reader.onerror = reject;
74
+ reader.readAsDataURL(file);
75
+ });
76
+ }
77
+
78
+ export function InputBar({
79
+ onSend, disabled, voiceEnabled, voiceSpeed = 1, agentLanguage = "zh-CN",
80
+ ttsProvider = "browser", ttsModel, voiceId,
81
+ pendingReply, onSpeakDone, installedSkills = [], onNewSession, onClearMessages, isStreaming,
82
+ currentModel, onModelChange,
83
+ }: InputBarProps) {
84
+ const t = useTranslations("chat");
85
+ const [text, setText] = useState("");
86
+ const [images, setImages] = useState<AttachedImage[]>([]);
87
+ const [mode, setMode] = useState<SendMode>("normal");
88
+ const [showModeMenu, setShowModeMenu] = useState(false);
89
+ const [showModelMenu, setShowModelMenu] = useState(false);
90
+ const [modelGroups, setModelGroups] = useState<ProviderGroup[]>([]);
91
+ const [recording, setRecording] = useState(false);
92
+ const [speaking, setSpeaking] = useState(false);
93
+ const [showPanel, setShowPanel] = useState(false);
94
+ const [slashFilter, setSlashFilter] = useState("");
95
+ const [selectedIdx, setSelectedIdx] = useState(0);
96
+ const [inputHistory, setInputHistory] = useState<string[]>([]);
97
+ const [historyIdx, setHistoryIdx] = useState(-1);
98
+ const [draftText, setDraftText] = useState("");
99
+ const [dragging, setDragging] = useState(false);
100
+
101
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
102
+ const fileInputRef = useRef<HTMLInputElement>(null);
103
+ const recognitionRef = useRef<ISpeechRecognition | null>(null);
104
+ const panelRef = useRef<HTMLDivElement>(null);
105
+ const dragCounterRef = useRef(0);
106
+
107
+ // Lazy-load model list when menu first opens
108
+ useEffect(() => {
109
+ if (!showModelMenu || modelGroups.length > 0) return;
110
+ fetch("/api/models").then(r => r.json()).then(setModelGroups).catch(() => {});
111
+ }, [showModelMenu, modelGroups.length]);
112
+
113
+ useEffect(() => {
114
+ const ta = textareaRef.current;
115
+ if (!ta) return;
116
+ ta.style.height = "auto";
117
+ ta.style.height = Math.min(ta.scrollHeight, 150) + "px";
118
+ }, [text]);
119
+
120
+ useEffect(() => {
121
+ if (!pendingReply || !voiceEnabled || typeof window === "undefined") return;
122
+
123
+ const speak = async () => {
124
+ if (ttsProvider === "openai") {
125
+ // OpenAI TTS
126
+ try {
127
+ setSpeaking(true);
128
+ const response = await fetch("/api/tts", {
129
+ method: "POST",
130
+ headers: { "Content-Type": "application/json" },
131
+ body: JSON.stringify({
132
+ text: pendingReply,
133
+ model: ttsModel || "tts-1",
134
+ voice: voiceId || "alloy",
135
+ speed: voiceSpeed || 1.0,
136
+ }),
137
+ });
138
+
139
+ if (!response.ok) throw new Error("TTS request failed");
140
+
141
+ const audioBlob = await response.blob();
142
+ const audioUrl = URL.createObjectURL(audioBlob);
143
+ const audio = new Audio(audioUrl);
144
+
145
+ audio.onended = () => {
146
+ setSpeaking(false);
147
+ onSpeakDone?.();
148
+ URL.revokeObjectURL(audioUrl);
149
+ };
150
+
151
+ audio.onerror = () => {
152
+ setSpeaking(false);
153
+ onSpeakDone?.();
154
+ URL.revokeObjectURL(audioUrl);
155
+ };
156
+
157
+ await audio.play();
158
+ } catch (error) {
159
+ console.error("OpenAI TTS error:", error);
160
+ setSpeaking(false);
161
+ onSpeakDone?.();
162
+ }
163
+ } else {
164
+ // Browser TTS
165
+ window.speechSynthesis?.cancel();
166
+ const utt = new SpeechSynthesisUtterance(pendingReply);
167
+ utt.lang = agentLanguage;
168
+ utt.rate = voiceSpeed || 1.0;
169
+ if (voiceId) {
170
+ const voices = window.speechSynthesis.getVoices();
171
+ const voice = voices.find(v => v.name === voiceId || v.voiceURI === voiceId);
172
+ if (voice) utt.voice = voice;
173
+ }
174
+ utt.onstart = () => setSpeaking(true);
175
+ utt.onend = () => { setSpeaking(false); onSpeakDone?.(); };
176
+ window.speechSynthesis.speak(utt);
177
+ }
178
+ };
179
+
180
+ speak();
181
+ }, [pendingReply, voiceEnabled, voiceSpeed, agentLanguage, ttsProvider, ttsModel, voiceId, onSpeakDone]);
182
+
183
+ useEffect(() => {
184
+ if (isStreaming && mode === "normal") setMode("steer");
185
+ if (!isStreaming && mode === "steer") setMode("normal");
186
+ }, [isStreaming, mode]);
187
+
188
+ const addImages = useCallback(async (files: File[]) => {
189
+ const valid = files.filter(f => f.type.startsWith("image/")).slice(0, MAX_IMAGES);
190
+ if (!valid.length) return;
191
+ const converted = await Promise.all(valid.map(fileToAttachedImage));
192
+ setImages(prev => [...prev, ...converted].slice(0, MAX_IMAGES));
193
+ }, []);
194
+
195
+ const removeImage = (id: string) => setImages(prev => prev.filter(i => i.id !== id));
196
+
197
+ const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
198
+ if (e.target.files) { void addImages(Array.from(e.target.files)); e.target.value = ""; }
199
+ };
200
+
201
+ const handlePaste = (e: React.ClipboardEvent) => {
202
+ const imageFiles = Array.from(e.clipboardData.items)
203
+ .filter(item => item.type.startsWith("image/"))
204
+ .map(item => item.getAsFile())
205
+ .filter(Boolean) as File[];
206
+ if (imageFiles.length) { e.preventDefault(); void addImages(imageFiles); }
207
+ };
208
+
209
+ const handleDragEnter = (e: React.DragEvent) => {
210
+ e.preventDefault(); dragCounterRef.current++;
211
+ if (Array.from(e.dataTransfer.items).some(i => i.type.startsWith("image/"))) setDragging(true);
212
+ };
213
+ const handleDragLeave = (e: React.DragEvent) => {
214
+ e.preventDefault(); dragCounterRef.current--;
215
+ if (dragCounterRef.current <= 0) { dragCounterRef.current = 0; setDragging(false); }
216
+ };
217
+ const handleDragOver = (e: React.DragEvent) => e.preventDefault();
218
+ const handleDrop = (e: React.DragEvent) => {
219
+ e.preventDefault(); dragCounterRef.current = 0; setDragging(false);
220
+ const files = Array.from(e.dataTransfer.files);
221
+ void addImages(files);
222
+ };
223
+
224
+ // Slash commands
225
+ const allCommands: SlashCommand[] = [
226
+ { id: "new", label: "新建会话", description: "开始全新对话", icon: <Plus className="h-4 w-4 text-blue-500" />, category: "system", action: () => { onNewSession?.(); setText(""); setShowPanel(false); } },
227
+ { id: "clear", label: "清空消息", description: "清空当前窗口消息", icon: <Trash2 className="h-4 w-4 text-red-500" />, category: "system", action: () => { onClearMessages?.(); setText(""); setShowPanel(false); } },
228
+ ...installedSkills.map((s) => ({
229
+ id: `skill:${s.name}`, label: s.name, description: s.description || "执行此 Skill",
230
+ icon: <Zap className="h-4 w-4 text-purple-500" />, category: "skill" as const,
231
+ insert: `/${s.name} `,
232
+ })),
233
+ ];
234
+
235
+ const filteredCommands = allCommands.filter(
236
+ (c) => !slashFilter || c.label.toLowerCase().includes(slashFilter.toLowerCase()) || c.description.toLowerCase().includes(slashFilter.toLowerCase())
237
+ );
238
+
239
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
240
+ const val = e.target.value; setText(val); setHistoryIdx(-1);
241
+ if (val === "/" || /(?:^|\n)\/$/.test(val)) { setSlashFilter(""); setSelectedIdx(0); setShowPanel(true); }
242
+ else if (showPanel) {
243
+ const match = val.match(/(?:^|\n)\/([^\s]*)$/);
244
+ if (match) { setSlashFilter(match[1]); setSelectedIdx(0); } else setShowPanel(false);
245
+ }
246
+ };
247
+
248
+ const selectCommand = useCallback((cmd: SlashCommand) => {
249
+ if (cmd.action) { cmd.action(); return; }
250
+ if (cmd.insert) {
251
+ const newText = text.replace(/(?:^|\n)\/[^\s]*$/, (m) => m.startsWith("\n") ? "\n" + cmd.insert : cmd.insert ?? "");
252
+ setText(newText ?? ""); setShowPanel(false); textareaRef.current?.focus();
253
+ }
254
+ }, [text]);
255
+
256
+ const handleSend = () => {
257
+ if ((!text.trim() && !images.length) || disabled) return;
258
+ const toSend = text.trim();
259
+ onSend(toSend, mode, images.length ? images : undefined);
260
+ if (toSend) setInputHistory((prev) => [toSend, ...prev.filter((x) => x !== toSend)].slice(0, 50));
261
+ setText(""); setImages([]); setHistoryIdx(-1); setShowPanel(false);
262
+ if (mode === "steer" || mode === "followup") setMode("normal");
263
+ };
264
+
265
+ const handleKeyDown = (e: React.KeyboardEvent) => {
266
+ if (showPanel) {
267
+ if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, filteredCommands.length - 1)); }
268
+ else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)); }
269
+ else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (filteredCommands[selectedIdx]) selectCommand(filteredCommands[selectedIdx]); }
270
+ else if (e.key === "Escape") setShowPanel(false);
271
+ return;
272
+ }
273
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
274
+ if (e.key === "ArrowUp" && !text.trim()) {
275
+ e.preventDefault();
276
+ const newIdx = Math.min(historyIdx + 1, inputHistory.length - 1);
277
+ if (newIdx >= 0 && inputHistory[newIdx]) { if (historyIdx === -1) setDraftText(text); setText(inputHistory[newIdx]); setHistoryIdx(newIdx); }
278
+ }
279
+ if (e.key === "ArrowDown" && historyIdx >= 0) {
280
+ e.preventDefault();
281
+ const newIdx = historyIdx - 1;
282
+ if (newIdx < 0) { setText(draftText); setHistoryIdx(-1); } else { setText(inputHistory[newIdx]); setHistoryIdx(newIdx); }
283
+ }
284
+ };
285
+
286
+ const toggleRecording = () => {
287
+ if (recording) { recognitionRef.current?.stop(); setRecording(false); return; }
288
+ const win = window as unknown as { SpeechRecognition?: ISpeechRecognitionCtor; webkitSpeechRecognition?: ISpeechRecognitionCtor };
289
+ const Ctor = win.SpeechRecognition ?? win.webkitSpeechRecognition;
290
+ if (!Ctor) return;
291
+ const rec = new Ctor();
292
+ rec.lang = agentLanguage; rec.continuous = false; rec.interimResults = false;
293
+ rec.onresult = (e) => setText((prev) => prev + (prev ? " " : "") + e.results[0][0].transcript);
294
+ rec.onend = () => setRecording(false); rec.onerror = () => setRecording(false);
295
+ rec.start(); recognitionRef.current = rec; setRecording(true);
296
+ };
297
+ const stopSpeaking = () => { window.speechSynthesis?.cancel(); setSpeaking(false); onSpeakDone?.(); };
298
+
299
+ useEffect(() => {
300
+ panelRef.current?.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView?.({ block: "nearest" });
301
+ }, [selectedIdx]);
302
+
303
+ const grouped = filteredCommands.reduce((acc, cmd) => {
304
+ if (!acc[cmd.category]) acc[cmd.category] = [];
305
+ acc[cmd.category].push(cmd);
306
+ return acc;
307
+ }, {} as Record<string, typeof filteredCommands>);
308
+ const CATEGORY_LABELS: Record<string, string> = { system: "系统命令", skill: "Skills" };
309
+ let flatIdx = 0;
310
+ const currentMode = MODE_META[mode];
311
+ const canSend = (text.trim().length > 0 || images.length > 0) && !(disabled && mode === "normal");
312
+ const borderColor = mode === "steer" ? "border-orange-400/60 dark:border-orange-700/60"
313
+ : mode === "followup" ? "border-blue-400/60 dark:border-blue-700/60"
314
+ : dragging ? "border-[#57BE6B] border-dashed" : "border-border";
315
+
316
+ return (
317
+ <div
318
+ className="border-t bg-background px-3 py-2 relative"
319
+ onDragEnter={handleDragEnter}
320
+ onDragLeave={handleDragLeave}
321
+ onDragOver={handleDragOver}
322
+ onDrop={handleDrop}
323
+ >
324
+ {/* Hidden file input */}
325
+ <input ref={fileInputRef} type="file" accept={ACCEPT_TYPES} multiple className="hidden" onChange={handleFileInput} />
326
+
327
+ {/* Slash command panel */}
328
+ {showPanel && (
329
+ <div ref={panelRef} className="absolute bottom-full left-3 right-3 mb-2 rounded-xl border bg-white dark:bg-zinc-800 shadow-2xl overflow-hidden z-50 max-h-64 overflow-y-auto">
330
+ <div className="flex items-center gap-2 px-3 py-2 border-b bg-secondary/30">
331
+ <Terminal className="h-3.5 w-3.5 text-muted-foreground" />
332
+ <span className="text-xs text-muted-foreground">{slashFilter ? `/${slashFilter}` : "输入命令名称过滤…"}</span>
333
+ <span className="ml-auto text-[10px] text-muted-foreground">↑↓ · Enter · Esc</span>
334
+ </div>
335
+ {filteredCommands.length === 0 ? (
336
+ <div className="px-4 py-5 text-center text-sm text-muted-foreground">没有匹配的命令</div>
337
+ ) : (
338
+ <div className="p-1">
339
+ {Object.entries(grouped).map(([cat, cmds]) => (
340
+ <div key={cat}>
341
+ <div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">{CATEGORY_LABELS[cat] ?? cat}</div>
342
+ {cmds.map((cmd) => {
343
+ const idx = flatIdx++;
344
+ const active = idx === selectedIdx;
345
+ return (
346
+ <button key={cmd.id} type="button" data-idx={idx}
347
+ onClick={() => selectCommand(cmd)} onMouseEnter={() => setSelectedIdx(idx)}
348
+ className={cn("w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors", active ? "bg-secondary" : "hover:bg-secondary/50")}>
349
+ <div className="h-7 w-7 rounded-lg bg-secondary flex items-center justify-center shrink-0">{cmd.icon}</div>
350
+ <div className="min-w-0 flex-1">
351
+ <div className="flex items-center gap-2">
352
+ <span className="text-sm font-medium">{cmd.label}</span>
353
+ <code className="text-[10px] text-muted-foreground font-mono bg-secondary px-1.5 py-0.5 rounded">/{cmd.id.replace("skill:", "")}</code>
354
+ </div>
355
+ <p className="text-xs text-muted-foreground truncate mt-0.5">{cmd.description}</p>
356
+ </div>
357
+ </button>
358
+ );
359
+ })}
360
+ </div>
361
+ ))}
362
+ </div>
363
+ )}
364
+ </div>
365
+ )}
366
+
367
+ {/* Main input card — no overflow-hidden here so the mode dropdown can escape */}
368
+ <div className={cn("rounded-xl border transition-colors", borderColor,
369
+ mode === "steer" ? "bg-orange-50/40 dark:bg-orange-900/10" :
370
+ mode === "followup" ? "bg-blue-50/40 dark:bg-blue-900/10" : "bg-background",
371
+ )}>
372
+ {/* Image previews — overflow-hidden only on this section for clipping */}
373
+ {images.length > 0 && (
374
+ <div className="flex gap-2 p-2 flex-wrap border-b border-border/60 rounded-t-xl overflow-hidden">
375
+ {images.map((img) => (
376
+ <div key={img.id} className="relative group/img">
377
+ {/* eslint-disable-next-line @next/next/no-img-element */}
378
+ <img src={img.previewUrl} alt={img.name}
379
+ className="h-16 w-16 object-cover rounded-lg border border-border/60" />
380
+ <button type="button" onClick={() => removeImage(img.id)}
381
+ className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-zinc-800 dark:bg-zinc-600 text-white flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity shadow">
382
+ <X className="h-3 w-3" />
383
+ </button>
384
+ </div>
385
+ ))}
386
+ {images.length < MAX_IMAGES && (
387
+ <button type="button" onClick={() => fileInputRef.current?.click()}
388
+ className="h-16 w-16 rounded-lg border-2 border-dashed border-border/60 flex items-center justify-center text-muted-foreground hover:border-[#57BE6B] hover:text-[#57BE6B] transition-colors">
389
+ <ImagePlus className="h-5 w-5" />
390
+ </button>
391
+ )}
392
+ </div>
393
+ )}
394
+
395
+ {/* Textarea */}
396
+ <textarea
397
+ ref={textareaRef}
398
+ value={text}
399
+ onChange={handleChange}
400
+ onKeyDown={handleKeyDown}
401
+ onPaste={handlePaste}
402
+ onBlur={() => setTimeout(() => setShowPanel(false), 150)}
403
+ placeholder={
404
+ mode === "steer" ? "引导生成方向(将中断当前输出)…" :
405
+ mode === "followup" ? "追加消息(当前完成后发送)…" :
406
+ `${t("inputPlaceholder")} ( / 唤出命令 · 粘贴/拖拽图片 )`
407
+ }
408
+ rows={1}
409
+ disabled={disabled && mode === "normal"}
410
+ className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 px-3 pt-2.5 pb-1 max-h-[150px]"
411
+ />
412
+
413
+ {/* Toolbar */}
414
+ <div className="flex items-center gap-1 px-2 py-1.5">
415
+ {/* Attach image */}
416
+ <button type="button" onClick={() => fileInputRef.current?.click()}
417
+ title={`上传图片(最多 ${MAX_IMAGES} 张,支持拖拽/粘贴)`}
418
+ disabled={images.length >= MAX_IMAGES}
419
+ className="flex items-center justify-center h-7 w-7 rounded-lg text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
420
+ <ImagePlus className="h-4 w-4" />
421
+ </button>
422
+
423
+ {/* Model selector */}
424
+ {onModelChange && (
425
+ <div className="relative">
426
+ <button type="button"
427
+ onClick={() => { setShowModelMenu(!showModelMenu); setShowModeMenu(false); }}
428
+ title={currentModel ? `当前模型:${currentModel.provider}/${currentModel.modelId}` : "切换模型"}
429
+ className={cn(
430
+ "flex items-center gap-1 h-7 px-2 rounded-lg text-xs transition-colors hover:bg-secondary/60",
431
+ currentModel ? "text-muted-foreground hover:text-foreground" : "text-muted-foreground",
432
+ )}
433
+ >
434
+ <Cpu className="h-3.5 w-3.5 shrink-0" />
435
+ {currentModel && (
436
+ <span className="font-mono max-w-[80px] truncate">
437
+ {currentModel.modelId.split("-").slice(-2).join("-")}
438
+ </span>
439
+ )}
440
+ <ChevronDown className="h-3 w-3 opacity-50 shrink-0" />
441
+ </button>
442
+
443
+ {showModelMenu && (
444
+ <div className="absolute bottom-full left-0 mb-1 w-72 rounded-xl border bg-white dark:bg-zinc-800 shadow-xl z-50 overflow-hidden">
445
+ <div className="px-3 py-2 border-b text-xs text-muted-foreground font-medium bg-secondary/30">
446
+ 切换模型(本次会话有效)
447
+ </div>
448
+ <div className="max-h-72 overflow-y-auto">
449
+ {modelGroups.length === 0 ? (
450
+ <div className="px-4 py-6 text-center text-sm text-muted-foreground">加载中…</div>
451
+ ) : modelGroups.map((group) => (
452
+ <div key={group.id}>
453
+ <div className="px-3 py-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider bg-secondary/20 sticky top-0">
454
+ {group.name}
455
+ </div>
456
+ {group.models.map((model) => {
457
+ const isActive = currentModel?.provider === model.providerId && currentModel?.modelId === model.id;
458
+ return (
459
+ <button key={model.id} type="button"
460
+ onClick={() => { onModelChange(model.providerId, model.id); setShowModelMenu(false); }}
461
+ className={cn(
462
+ "w-full flex items-start gap-2.5 px-3 py-2 text-left transition-colors hover:bg-secondary/50",
463
+ isActive && "bg-[#57BE6B]/10 dark:bg-[#57BE6B]/15",
464
+ )}
465
+ >
466
+ <div className="flex-1 min-w-0">
467
+ <div className="flex items-center gap-2">
468
+ <span className={cn("text-sm font-medium truncate", isActive && "text-[#57BE6B]")}>
469
+ {model.name}
470
+ </span>
471
+ {isActive && <span className="text-[10px] text-[#57BE6B] font-medium shrink-0">当前</span>}
472
+ </div>
473
+ <div className="flex items-center gap-2 mt-0.5">
474
+ <span className="text-[10px] text-muted-foreground font-mono">
475
+ {Math.round(model.contextWindow / 1000)}K ctx
476
+ </span>
477
+ {model.supportsThinking && (
478
+ <span className="text-[10px] text-purple-500">🧠 thinking</span>
479
+ )}
480
+ {model.inputTypes.includes("image") && (
481
+ <span className="text-[10px] text-blue-500">🖼 vision</span>
482
+ )}
483
+ {model.costInput && (
484
+ <span className="text-[10px] text-muted-foreground ml-auto shrink-0">
485
+ ${model.costInput}/M
486
+ </span>
487
+ )}
488
+ </div>
489
+ </div>
490
+ </button>
491
+ );
492
+ })}
493
+ </div>
494
+ ))}
495
+ </div>
496
+ </div>
497
+ )}
498
+ </div>
499
+ )}
500
+
501
+ {/* Voice */}
502
+ {voiceEnabled && (
503
+ speaking ? (
504
+ <button type="button" onClick={stopSpeaking} title="停止朗读"
505
+ className="flex items-center justify-center h-7 w-7 rounded-lg text-primary hover:bg-secondary/60 transition-colors">
506
+ <VolumeX className="h-4 w-4" />
507
+ </button>
508
+ ) : (
509
+ <button type="button" onClick={toggleRecording}
510
+ title={recording ? "停止录音" : "语音输入"}
511
+ className={cn("flex items-center justify-center h-7 w-7 rounded-lg transition-colors hover:bg-secondary/60",
512
+ recording ? "text-destructive animate-pulse" : "text-muted-foreground hover:text-foreground")}>
513
+ {recording ? <MicOff className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
514
+ </button>
515
+ )
516
+ )}
517
+
518
+ <div className="flex-1" />
519
+
520
+ {/* Mode picker */}
521
+ <div className="relative">
522
+ <button type="button" onClick={() => setShowModeMenu(!showModeMenu)}
523
+ className={cn("flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium transition-colors hover:bg-secondary/60", currentMode.activeColor)}>
524
+ {currentMode.label}
525
+ <ChevronDown className="h-3 w-3 opacity-60" />
526
+ </button>
527
+ {showModeMenu && (
528
+ <div className="absolute bottom-full right-0 mb-1 min-w-[160px] rounded-xl border bg-white dark:bg-zinc-800 shadow-xl z-50 p-1 space-y-0.5">
529
+ {(Object.entries(MODE_META) as [SendMode, typeof MODE_META[SendMode]][]).map(([m, meta]) => (
530
+ <button key={m} type="button" onClick={() => { setMode(m); setShowModeMenu(false); }}
531
+ className={cn("w-full text-left rounded-lg px-3 py-2 text-sm transition-colors", mode === m ? "bg-secondary" : "hover:bg-secondary/50")}>
532
+ <div className={cn("font-medium", MODE_META[m].activeColor)}>{meta.label}</div>
533
+ <div className="text-[11px] text-muted-foreground">{meta.desc}</div>
534
+ </button>
535
+ ))}
536
+ </div>
537
+ )}
538
+ </div>
539
+
540
+ {/* Send button */}
541
+ <button type="button" onClick={handleSend} disabled={!canSend}
542
+ title="发送(Enter)"
543
+ className={cn(
544
+ "flex items-center justify-center h-7 w-7 rounded-lg text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
545
+ currentMode.color,
546
+ )}>
547
+ <Send className="h-3.5 w-3.5" />
548
+ </button>
549
+ </div>
550
+ </div>
551
+
552
+ {/* Drag overlay */}
553
+ {dragging && (
554
+ <div className="absolute inset-0 m-1 rounded-xl border-2 border-dashed border-[#57BE6B] bg-[#57BE6B]/5 flex flex-col items-center justify-center gap-1 pointer-events-none">
555
+ <ImagePlus className="h-6 w-6 text-[#57BE6B]" />
556
+ <p className="text-sm font-medium text-[#57BE6B]">松开鼠标上传图片</p>
557
+ </div>
558
+ )}
559
+ </div>
560
+ );
561
+ }