@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,833 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useRef, useCallback } from "react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useLocale } from "next-intl";
6
+ import {
7
+ Plus, History, ChevronLeft, ChevronDown, Info, Zap, Check, Trash2, Square,
8
+ } from "lucide-react";
9
+ import type { DigitalAgent, ChatMessage, AgentChatEvent, ToolCall, SessionStats, AttachedImage } from "@/types/agent";
10
+ import type { SendMode } from "@/components/chat/input-bar";
11
+ import { MessageBubble, TypingBubble } from "@/components/chat/message-bubble";
12
+ import { InputBar } from "@/components/chat/input-bar";
13
+ import { ScrollArea } from "@/components/ui/scroll-area";
14
+ import { cn } from "@/lib/utils";
15
+ import { v4 as uuidv4 } from "uuid";
16
+ import { toast } from "@/hooks/use-toast";
17
+ import type { SkillInfo } from "@/lib/skills";
18
+
19
+ interface SessionInfo {
20
+ id: string; title: string; session_file: string; created_at: number;
21
+ }
22
+
23
+ const TIMESTAMP_GAP_MS = 5 * 60 * 1000;
24
+
25
+ export default function ChatPage({ params }: { params: Promise<{ id: string }> }) {
26
+ const t = useTranslations("chat");
27
+ const locale = useLocale();
28
+
29
+ const [agentId, setAgentId] = useState("");
30
+ const [agent, setAgent] = useState<DigitalAgent | null>(null);
31
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
32
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
33
+ const [currentSessionFile, setCurrentSessionFile] = useState<string | undefined>();
34
+ const [streaming, setStreaming] = useState(false);
35
+ const [showSidebar, setShowSidebar] = useState(false);
36
+ const [showInfoPanel, setShowInfoPanel] = useState(false);
37
+ const [pendingTTS, setPendingTTS] = useState<string | undefined>();
38
+ const [compacting, setCompacting] = useState(false);
39
+ const [sessionStats, setSessionStats] = useState<SessionStats | null>(null);
40
+ const [showScrollBtn, setShowScrollBtn] = useState(false);
41
+ const [confirmClearAll, setConfirmClearAll] = useState(false);
42
+ const [pendingFollowup, setPendingFollowup] = useState<string | null>(null);
43
+ const [sessionSkills, setSessionSkills] = useState<string[] | null>(null);
44
+ const [installedSkills, setInstalledSkills] = useState<SkillInfo[]>([]);
45
+ // null = use agent's configured model; set to override for this session
46
+ const [sessionModel, setSessionModel] = useState<{ provider: string; modelId: string } | null>(null);
47
+
48
+ const bottomRef = useRef<HTMLDivElement>(null);
49
+ const chatScrollRef = useRef<HTMLDivElement>(null);
50
+ const abortControllerRef = useRef<AbortController | null>(null);
51
+ const pendingSteerRef = useRef<string | null>(null);
52
+
53
+ // stable refs — doSend stays the same object across renders
54
+ const agentRef = useRef(agent);
55
+ const agentIdRef = useRef(agentId);
56
+ const sessionFileRef = useRef(currentSessionFile);
57
+ const sessionSkillsRef = useRef(sessionSkills);
58
+ const sessionModelRef = useRef(sessionModel);
59
+ useEffect(() => { agentRef.current = agent; }, [agent]);
60
+ useEffect(() => { agentIdRef.current = agentId; }, [agentId]);
61
+ useEffect(() => { sessionFileRef.current = currentSessionFile; }, [currentSessionFile]);
62
+ useEffect(() => { sessionSkillsRef.current = sessionSkills; }, [sessionSkills]);
63
+ useEffect(() => { sessionModelRef.current = sessionModel; }, [sessionModel]);
64
+
65
+ useEffect(() => {
66
+ params.then(async ({ id }) => {
67
+ setAgentId(id);
68
+ const [agentRes, sessRes, skillsRes] = await Promise.all([
69
+ fetch(`/api/agents/${id}`).then((r) => r.json()),
70
+ fetch(`/api/sessions/${id}`).then((r) => r.json()),
71
+ fetch("/api/skills").then((r) => r.json()),
72
+ ]);
73
+ setAgent(agentRes);
74
+ setSessions(sessRes);
75
+ setInstalledSkills(skillsRes);
76
+ });
77
+ }, [params]);
78
+
79
+ // auto-scroll only when already at bottom
80
+ useEffect(() => {
81
+ if (!showScrollBtn) {
82
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
83
+ }
84
+ }, [messages, streaming, showScrollBtn]);
85
+
86
+ const handleScroll = useCallback(() => {
87
+ const el = chatScrollRef.current;
88
+ if (!el) return;
89
+ setShowScrollBtn(el.scrollHeight - el.scrollTop - el.clientHeight > 120);
90
+ }, []);
91
+
92
+ const effectiveSkills = sessionSkills ?? (agent?.enabledSkills ?? []);
93
+
94
+ const stopStreaming = useCallback(() => {
95
+ abortControllerRef.current?.abort();
96
+ abortControllerRef.current = null;
97
+ setStreaming(false);
98
+ }, []);
99
+
100
+ // ── Core send (no mode/streaming guard — callers handle that) ─────────────
101
+ const doSend = useCallback(async (text: string, images?: AttachedImage[]) => {
102
+ const ag = agentRef.current;
103
+ const aid = agentIdRef.current;
104
+ if (!ag || !aid) return;
105
+
106
+ const userMsg: ChatMessage = { id: uuidv4(), role: "user", content: text, images, createdAt: new Date() };
107
+ setMessages((prev) => [...prev, userMsg]);
108
+ setStreaming(true);
109
+
110
+ const assistantId = uuidv4();
111
+ setMessages((prev) => [...prev, { id: assistantId, role: "assistant", content: "", toolCalls: [], createdAt: new Date() }]);
112
+
113
+ const toolMap = new Map<string, ToolCall>();
114
+ const abort = new AbortController();
115
+ abortControllerRef.current = abort;
116
+
117
+ try {
118
+ const res = await fetch(`/api/chat/${aid}`, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ signal: abort.signal,
122
+ body: JSON.stringify({
123
+ message: text,
124
+ sessionFile: sessionFileRef.current,
125
+ overrideSkills: sessionSkillsRef.current ?? undefined,
126
+ overrideProvider: sessionModelRef.current?.provider,
127
+ overrideModelId: sessionModelRef.current?.modelId,
128
+ images: images?.map(img => ({ data: img.data, mimeType: img.mimeType })),
129
+ }),
130
+ });
131
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
132
+
133
+ const reader = res.body.getReader();
134
+ const decoder = new TextDecoder();
135
+ let buffer = "";
136
+ while (true) {
137
+ const { done, value } = await reader.read();
138
+ if (done) break;
139
+ buffer += decoder.decode(value, { stream: true });
140
+ const lines = buffer.split("\n");
141
+ buffer = lines.pop() ?? "";
142
+ for (const line of lines) {
143
+ if (!line.startsWith("data: ")) continue;
144
+ try {
145
+ const ev: AgentChatEvent = JSON.parse(line.slice(6));
146
+ handleSSEEvent(ev, assistantId, toolMap, text, ag);
147
+ } catch { /* ignore */ }
148
+ }
149
+ }
150
+ } catch (e) {
151
+ if ((e as Error).name === "AbortError") return;
152
+ const msg = String(e);
153
+ setMessages((prev) => prev.map((m) =>
154
+ m.id === assistantId ? { ...m, content: `⚠️ ${msg}` } : m
155
+ ));
156
+ toast({ title: "发送失败", description: msg, variant: "destructive" });
157
+ } finally {
158
+ abortControllerRef.current = null;
159
+ setStreaming(false);
160
+ }
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ }, []);
163
+
164
+ // ── SSE event handler (local to doSend call, not a hook) ─────────────────
165
+ function handleSSEEvent(
166
+ event: AgentChatEvent,
167
+ assistantId: string,
168
+ toolMap: Map<string, ToolCall>,
169
+ userText: string,
170
+ ag: DigitalAgent,
171
+ ) {
172
+ switch (event.type) {
173
+ case "message_update":
174
+ setMessages((prev) => prev.map((m) =>
175
+ m.id === assistantId ? { ...m, content: m.content + event.delta } : m));
176
+ break;
177
+ // FIX Bug1: thinking_delta accumulation
178
+ case "thinking_delta":
179
+ setMessages((prev) => prev.map((m) =>
180
+ m.id === assistantId ? { ...m, thinking: (m.thinking ?? "") + event.delta } : m));
181
+ break;
182
+ case "tool_start": {
183
+ const tc: ToolCall = { id: event.toolCallId, name: event.toolName, isLoading: true };
184
+ toolMap.set(event.toolCallId, tc);
185
+ setMessages((prev) => prev.map((m) =>
186
+ m.id === assistantId ? { ...m, toolCalls: [...(m.toolCalls ?? []), tc] } : m));
187
+ break;
188
+ }
189
+ case "tool_update": {
190
+ const tc = toolMap.get(event.toolCallId);
191
+ if (tc) {
192
+ const u = { ...tc, input: event.content };
193
+ toolMap.set(event.toolCallId, u);
194
+ setMessages((prev) => prev.map((m) =>
195
+ m.id === assistantId
196
+ ? { ...m, toolCalls: m.toolCalls?.map((t) => t.id === event.toolCallId ? u : t) }
197
+ : m));
198
+ }
199
+ break;
200
+ }
201
+ case "tool_end": {
202
+ const tc = toolMap.get(event.toolCallId);
203
+ if (tc) {
204
+ const u: ToolCall = {
205
+ ...tc,
206
+ isLoading: false,
207
+ isError: event.isError,
208
+ output: event.output,
209
+ fullOutputPath: event.fullOutputPath,
210
+ truncated: event.truncated,
211
+ };
212
+ toolMap.set(event.toolCallId, u);
213
+ setMessages((prev) => prev.map((m) =>
214
+ m.id === assistantId
215
+ ? { ...m, toolCalls: m.toolCalls?.map((t) => t.id === event.toolCallId ? u : t) }
216
+ : m));
217
+ }
218
+ break;
219
+ }
220
+ case "compaction_start": setCompacting(true); break;
221
+ case "compaction_end": setCompacting(false); break;
222
+ // FIX Bug4: error event
223
+ case "error":
224
+ toast({ title: "对话出错", description: event.message, variant: "destructive" });
225
+ setMessages((prev) => prev.map((m) =>
226
+ m.id === assistantId && !m.content ? { ...m, content: `⚠️ ${event.message}` } : m));
227
+ break;
228
+ // FIX Bug5: aborted event
229
+ case "aborted":
230
+ setStreaming(false);
231
+ break;
232
+ // FIX Bug6: session title from actual user message
233
+ case "session_file": {
234
+ const sf = event.path;
235
+ setCurrentSessionFile(sf);
236
+ const title = userText.slice(0, 60) || "新对话";
237
+ setSessions((prev) => {
238
+ if (prev.find((s) => s.session_file === sf)) return prev;
239
+ return [{ id: uuidv4(), title, session_file: sf, created_at: Date.now() }, ...prev];
240
+ });
241
+ break;
242
+ }
243
+ // NEW: session_stats display
244
+ case "session_stats":
245
+ setSessionStats(event.stats);
246
+ break;
247
+ case "done":
248
+ // 自动朗读功能已禁用
249
+ // if (ag.voice.enabled) {
250
+ // setMessages((prev) => {
251
+ // const last = [...prev].reverse().find((m) => m.role === "assistant");
252
+ // if (last?.content) setPendingTTS(last.content);
253
+ // return prev;
254
+ // });
255
+ // }
256
+ setStreaming(false);
257
+ break;
258
+ }
259
+ }
260
+
261
+ // FIX Bug2+3: steer/followup — fire after streaming ends
262
+ useEffect(() => {
263
+ if (streaming) return;
264
+ if (pendingSteerRef.current) {
265
+ const msg = pendingSteerRef.current;
266
+ pendingSteerRef.current = null;
267
+ void doSend(msg);
268
+ } else if (pendingFollowup) {
269
+ const msg = pendingFollowup;
270
+ setPendingFollowup(null);
271
+ void doSend(msg);
272
+ }
273
+ // eslint-disable-next-line react-hooks/exhaustive-deps
274
+ }, [streaming]);
275
+
276
+ // FIX Bug2+3: sendMessage with full mode support + images
277
+ const sendMessage = useCallback((text: string, mode: SendMode = "normal", images?: AttachedImage[]) => {
278
+ if (mode === "steer") {
279
+ if (streaming) { pendingSteerRef.current = text; stopStreaming(); }
280
+ else void doSend(text, images);
281
+ return;
282
+ }
283
+ if (mode === "followup") {
284
+ if (streaming) {
285
+ setPendingFollowup(text);
286
+ toast({ title: "消息已排队", description: "当前回复完成后自动发送" });
287
+ } else void doSend(text, images);
288
+ return;
289
+ }
290
+ if (!streaming) void doSend(text, images);
291
+ }, [streaming, doSend, stopStreaming]);
292
+
293
+ // ── Session management ────────────────────────────────────────────────────
294
+ const newSession = () => {
295
+ setCurrentSessionFile(undefined); setMessages([]); setSessionSkills(null); setSessionStats(null); setSessionModel(null);
296
+ };
297
+
298
+ const loadSession = async (sf: string) => {
299
+ setCurrentSessionFile(sf); setMessages([]); setShowSidebar(false); setSessionStats(null);
300
+ try {
301
+ const { messages: history } = await fetch("/api/sessions/history", {
302
+ method: "POST", headers: { "Content-Type": "application/json" },
303
+ body: JSON.stringify({ sessionFile: sf }),
304
+ }).then((r) => r.json()) as {
305
+ messages: {
306
+ id: string; role: "user"|"assistant"; content: string;
307
+ thinking?: string; toolCalls?: ToolCall[]; createdAt: string;
308
+ }[];
309
+ };
310
+ // FIX Bug7+8: restore toolCalls and thinking from history
311
+ setMessages(history.map((m) => ({
312
+ id: m.id, role: m.role, content: m.content,
313
+ thinking: m.thinking, toolCalls: m.toolCalls ?? [],
314
+ createdAt: new Date(m.createdAt),
315
+ })));
316
+ } catch (e) {
317
+ toast({ title: "加载历史失败", description: String(e), variant: "destructive" });
318
+ }
319
+ };
320
+
321
+ // NEW: onRetry — resend the user message before the given assistant message
322
+ const handleRetry = useCallback((message: ChatMessage) => {
323
+ if (streaming) return;
324
+ const idx = messages.findIndex((m) => m.id === message.id);
325
+ for (let i = idx - 1; i >= 0; i--) {
326
+ if (messages[i].role === "user") {
327
+ setMessages((prev) => prev.slice(0, i + 1));
328
+ void doSend(messages[i].content);
329
+ return;
330
+ }
331
+ }
332
+ }, [messages, streaming, doSend]);
333
+
334
+ // NEW: onFork — branch from just before the selected message
335
+ const handleFork = useCallback((message: ChatMessage) => {
336
+ const idx = messages.findIndex((m) => m.id === message.id);
337
+ setCurrentSessionFile(undefined);
338
+ setMessages(idx >= 0 ? messages.slice(0, idx) : []);
339
+ setSessionSkills(null); setSessionStats(null);
340
+ toast({ title: "会话已分叉", description: "从所选消息之前创建了新分支", variant: "success" });
341
+ }, [messages]);
342
+
343
+ const deleteSession = async (e: React.MouseEvent, sid: string, sf: string) => {
344
+ e.stopPropagation();
345
+ await fetch(`/api/sessions/${agentId}/${sid}`, { method: "DELETE" });
346
+ setSessions((prev) => prev.filter((s) => s.id !== sid));
347
+ if (currentSessionFile === sf) { setCurrentSessionFile(undefined); setMessages([]); }
348
+ };
349
+
350
+ const clearAllSessions = async () => {
351
+ if (!confirmClearAll) { setConfirmClearAll(true); setTimeout(() => setConfirmClearAll(false), 3000); return; }
352
+ await fetch(`/api/sessions/${agentId}`, { method: "DELETE" });
353
+ setSessions([]); setCurrentSessionFile(undefined); setMessages([]);
354
+ setConfirmClearAll(false);
355
+ };
356
+
357
+ const toggleSessionSkill = (name: string) => {
358
+ const base = sessionSkills ?? (agent?.enabledSkills ?? []);
359
+ setSessionSkills(base.includes(name) ? base.filter((s) => s !== name) : [...base, name]);
360
+ };
361
+
362
+ if (!agent) return <div className="flex h-full items-center justify-center text-muted-foreground">Loading…</div>;
363
+
364
+ const shouldShowTimestamp = (i: number) => {
365
+ if (i === 0) return true;
366
+ return new Date(messages[i].createdAt).getTime() - new Date(messages[i-1].createdAt).getTime() > TIMESTAMP_GAP_MS;
367
+ };
368
+
369
+ const ctxPct = sessionStats?.contextUsage?.percent ?? 0;
370
+ const ctxColor = ctxPct > 80 ? "bg-red-500" : ctxPct > 60 ? "bg-yellow-500" : "bg-[#57BE6B]";
371
+
372
+ return (
373
+ <div className="flex h-full bg-gradient-to-br from-gray-50 to-gray-100 dark:from-slate-900 dark:via-purple-900/10 dark:to-slate-900">
374
+ {/* ── 历史会话侧边栏 ──────────────────────────────────────────── */}
375
+ <div className={cn(
376
+ "shrink-0 transition-all duration-300 overflow-hidden",
377
+ "bg-[hsl(var(--neo-bg))] backdrop-blur-xl border-r border-gray-200 dark:bg-slate-900/95 dark:backdrop-blur-2xl dark:border-slate-700/50",
378
+ showSidebar ? "w-[280px]" : "w-0"
379
+ )}>
380
+ <div className="p-4 space-y-3">
381
+ <div className="flex items-center justify-between mb-2">
382
+ <span className="text-[14px] font-bold text-gray-800 dark:text-white">
383
+ 会话历史
384
+ </span>
385
+ <button
386
+ onClick={() => setShowSidebar(false)}
387
+ className="h-8 w-8 rounded-xl neo-button hover:text-blue-600 dark:hover:bg-cyan-500/20 dark:border-transparent dark:hover:border-cyan-500/50 flex items-center justify-center transition-all duration-200 hover:scale-110"
388
+ title="关闭侧边栏"
389
+ >
390
+ <ChevronLeft className="h-4 w-4 text-gray-500 dark:text-gray-400 dark:group-hover:text-cyan-400 transition-colors" />
391
+ </button>
392
+ </div>
393
+
394
+ <button
395
+ onClick={newSession}
396
+ className="group w-full h-10 rounded-xl neo-button bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 dark:from-cyan-600 dark:to-blue-600 dark:hover:from-cyan-500 dark:hover:to-blue-500 text-white text-[13px] font-semibold shadow-lg hover:shadow-blue-500/30 dark:hover:shadow-cyan-500/50 transition-all duration-200 hover:scale-[1.02] hover:-translate-y-0.5 flex items-center justify-center gap-2"
397
+ >
398
+ <Plus className="h-4 w-4 transition-transform duration-200 group-hover:rotate-90" />
399
+ <span>{t("newSession")}</span>
400
+ </button>
401
+
402
+ {sessions.length > 0 && (
403
+ <button
404
+ onClick={clearAllSessions}
405
+ className={cn(
406
+ "w-full h-8 rounded-xl text-[12px] font-medium transition-all duration-200 flex items-center justify-center gap-2 border hover:scale-[1.02]",
407
+ confirmClearAll
408
+ ? "bg-red-100 text-red-600 border-red-300 hover:bg-red-200 dark:bg-red-500/20 dark:text-red-400 dark:border-red-500/50 dark:hover:bg-red-500/30"
409
+ : "neo-button text-gray-600 hover:text-red-600 dark:hover:bg-slate-800 dark:text-gray-400 dark:hover:text-red-400 border-transparent dark:hover:border-red-500/30"
410
+ )}
411
+ >
412
+ <Trash2 className="h-3.5 w-3.5 transition-transform duration-200 hover:rotate-12" />
413
+ <span>{confirmClearAll ? "再次点击确认清空" : "清空全部历史"}</span>
414
+ </button>
415
+ )}
416
+ </div>
417
+
418
+ <ScrollArea className="h-[calc(100vh-10rem)]">
419
+ <div className="px-3 pb-3 space-y-2">
420
+ {sessions.length === 0 ? (
421
+ <div className="text-center py-12">
422
+ <div className="w-16 h-16 mx-auto mb-4 rounded-2xl neo-card bg-white dark:bg-slate-800 border-gray-200 dark:border-slate-700 flex items-center justify-center">
423
+ <History className="h-7 w-7 text-gray-400 dark:text-slate-500" />
424
+ </div>
425
+ <p className="text-[13px] text-gray-500">{t("noSessions")}</p>
426
+ </div>
427
+ ) : sessions.map((s) => (
428
+ <div key={s.id} className="relative group">
429
+ <button
430
+ type="button"
431
+ onClick={() => loadSession(s.session_file)}
432
+ className={cn(
433
+ "w-full text-left px-3 py-3 rounded-xl transition-all duration-200 border hover:scale-[1.02] hover:-translate-y-0.5",
434
+ s.session_file === currentSessionFile
435
+ ? "neo-card-inset bg-blue-50 text-blue-700 border-blue-200 dark:bg-gradient-to-r dark:from-cyan-600/20 dark:to-blue-600/20 dark:border-cyan-500/50 dark:shadow-lg dark:shadow-cyan-500/20"
436
+ : "neo-button text-gray-700 dark:border-slate-700/50 dark:hover:border-cyan-500/30 dark:hover:bg-slate-800/50"
437
+ )}
438
+ >
439
+ {s.session_file === currentSessionFile && (
440
+ <div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-blue-500 to-indigo-500 dark:from-cyan-400 dark:to-blue-500 rounded-r-full shadow-lg dark:shadow-cyan-500/50"></div>
441
+ )}
442
+ <p className={cn(
443
+ "text-[13px] truncate leading-snug transition-colors",
444
+ s.session_file === currentSessionFile
445
+ ? "font-bold text-cyan-400"
446
+ : "font-medium text-gray-300 group-hover:text-white"
447
+ )}>
448
+ {s.title}
449
+ </p>
450
+ <p className="text-[11px] text-gray-500 mt-1 flex items-center gap-1.5 group-hover:text-gray-400 transition-colors">
451
+ <span className="w-1 h-1 rounded-full bg-cyan-500"></span>
452
+ {new Date(s.created_at).toLocaleDateString(locale)}
453
+ </p>
454
+ </button>
455
+ <button
456
+ type="button"
457
+ onClick={(e) => deleteSession(e, s.id, s.session_file)}
458
+ className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 h-7 w-7 rounded-lg hover:bg-red-500/20 text-gray-500 hover:text-red-400 flex items-center justify-center transition-all duration-200 border border-transparent hover:border-red-500/50 hover:scale-110"
459
+ >
460
+ <Trash2 className="h-3.5 w-3.5" />
461
+ </button>
462
+ </div>
463
+ ))}
464
+ </div>
465
+ </ScrollArea>
466
+ </div>
467
+
468
+ {/* ── 主聊天区 - 科技感设计 ───────────────────────────────────────────────── */}
469
+ <div className="flex flex-1 flex-col min-w-0">
470
+ {/* 顶部栏 - 科技感设计 */}
471
+ <div className="flex items-center h-[56px] px-4 shrink-0 glass-effect border-b border-purple-500/20">
472
+ <button
473
+ onClick={() => { setShowSidebar(true); setShowInfoPanel(false); }}
474
+ className="h-9 w-9 rounded-lg hover:bg-white/10 flex items-center justify-center transition-all hover:scale-110 border border-transparent hover:border-purple-500/30"
475
+ title="打开历史记录"
476
+ >
477
+ <History className="h-4 w-4 text-cyan-400" />
478
+ </button>
479
+
480
+ <div className="flex-1 flex items-center justify-center gap-3">
481
+ <div className="h-10 w-10 rounded-xl bg-gradient-to-br from-purple-600/30 to-cyan-500/30 flex items-center justify-center text-2xl shadow-lg neon-glow-purple border border-purple-500/30">
482
+ {agent.avatar}
483
+ </div>
484
+ <span className="text-[16px] font-bold bg-gradient-to-r from-cyan-400 to-purple-400 bg-clip-text text-transparent">
485
+ {agent.name}
486
+ </span>
487
+ </div>
488
+
489
+ <div className="flex items-center gap-2">
490
+ {compacting && (
491
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded-full glass-effect border border-cyan-500/30">
492
+ <div className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse neon-glow"></div>
493
+ <span className="text-[11px] font-semibold text-cyan-400">压缩中</span>
494
+ </div>
495
+ )}
496
+
497
+ {sessionStats?.contextUsage && (
498
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded-full glass-effect border border-purple-500/30" title={`Context: ${ctxPct}%`}>
499
+ <div className="h-1.5 w-16 rounded-full bg-white/10 overflow-hidden">
500
+ <div className={cn("h-full rounded-full transition-all duration-500", ctxColor)} style={{ width: `${ctxPct}%` }} />
501
+ </div>
502
+ <span className="text-[11px] font-semibold text-gray-300">{ctxPct}%</span>
503
+ </div>
504
+ )}
505
+
506
+ {streaming && (
507
+ <button
508
+ onClick={stopStreaming}
509
+ className="h-9 w-9 rounded-lg hover:bg-red-500/20 text-red-400 flex items-center justify-center transition-all hover:scale-110 border border-transparent hover:border-red-500/50"
510
+ title="停止生成"
511
+ >
512
+ <Square className="h-4 w-4 fill-current" />
513
+ </button>
514
+ )}
515
+
516
+ <button
517
+ onClick={() => { setShowInfoPanel(!showInfoPanel); setShowSidebar(false); }}
518
+ className={cn(
519
+ "h-9 w-9 rounded-lg flex items-center justify-center transition-all hover:scale-110 border",
520
+ showInfoPanel
521
+ ? "bg-purple-500/20 text-purple-400 border-purple-500/50 neon-glow-purple"
522
+ : "hover:bg-white/10 text-gray-400 border-transparent hover:border-purple-500/30"
523
+ )}
524
+ title="数字人信息 & Skills"
525
+ >
526
+ <Info className="h-4 w-4" />
527
+ </button>
528
+ </div>
529
+ </div>
530
+
531
+ {/* 会话统计栏 - 科技感设计 */}
532
+ {sessionStats && (
533
+ <div className="flex items-center justify-center gap-3 px-4 py-2 glass-effect border-b border-purple-500/20 text-[11px] shrink-0">
534
+ <div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
535
+ <span className="text-cyan-400 font-semibold">↑</span>
536
+ <span className="font-bold text-cyan-300">{sessionStats.tokens.input.toLocaleString()}</span>
537
+ </div>
538
+ <div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-purple-500/10 border border-purple-500/30">
539
+ <span className="text-purple-400 font-semibold">↓</span>
540
+ <span className="font-bold text-purple-300">{sessionStats.tokens.output.toLocaleString()}</span>
541
+ </div>
542
+ {sessionStats.cost > 0 && (
543
+ <div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-green-500/10 border border-green-500/30">
544
+ <span className="font-bold text-green-300">≈ ${sessionStats.cost.toFixed(4)}</span>
545
+ </div>
546
+ )}
547
+ {sessionStats.contextUsage && (
548
+ <div className={cn(
549
+ "flex items-center gap-1.5 px-2.5 py-1 rounded-lg border",
550
+ ctxPct > 80
551
+ ? "bg-red-500/10 border-red-500/30 text-red-400"
552
+ : "bg-white/5 border-white/10 text-gray-300"
553
+ )}>
554
+ <span className="font-bold">
555
+ context {ctxPct}% / {sessionStats.contextUsage.contextWindow.toLocaleString()}
556
+ </span>
557
+ </div>
558
+ )}
559
+ </div>
560
+ )}
561
+
562
+ {/* 内容区(聊天 + 信息面板并排)- 科技感设计 */}
563
+ <div className="flex flex-1 min-h-0">
564
+ <div className="flex flex-col flex-1 min-w-0 relative">
565
+ {/* 聊天消息区 */}
566
+ <div ref={chatScrollRef} onScroll={handleScroll}
567
+ className="flex-1 overflow-y-auto bg-white dark:bg-slate-900/50">
568
+ <div className="py-4 space-y-1">
569
+ {messages.length === 0 && (
570
+ <div className="flex justify-center mt-20 mb-4 text-center">
571
+ <div className="max-w-sm">
572
+ <div className="relative w-24 h-24 mx-auto mb-6">
573
+ <div className="absolute inset-0 bg-gradient-to-r from-blue-400/20 to-indigo-400/20 dark:from-purple-600/30 dark:to-cyan-500/30 blur-2xl animate-pulse"></div>
574
+ <div className="relative w-24 h-24 rounded-2xl neo-card bg-gradient-to-br from-blue-100 to-indigo-100 dark:from-purple-600/20 dark:to-cyan-500/20 border-2 border-blue-200 dark:border-purple-500/30 flex items-center justify-center text-5xl neon-glow-purple">
575
+ {agent.avatar}
576
+ </div>
577
+ </div>
578
+ <p className="text-[18px] font-bold text-gray-800 dark:bg-gradient-to-r dark:from-cyan-400 dark:to-purple-400 dark:bg-clip-text dark:text-transparent mb-3">
579
+ {agent.name}
580
+ </p>
581
+ <p className="text-[13px] text-gray-600 dark:text-gray-400 leading-relaxed px-4">
582
+ {agent.description || t("welcomeTitle")}
583
+ </p>
584
+ {effectiveSkills.length > 0 && (
585
+ <div className="flex flex-wrap justify-center gap-2 mt-4">
586
+ {effectiveSkills.map((s) => (
587
+ <span key={s} className="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg glass-effect border border-purple-500/30 text-purple-300 font-medium">
588
+ <Zap className="h-3 w-3 text-cyan-400" />
589
+ {s}
590
+ </span>
591
+ ))}
592
+ </div>
593
+ )}
594
+ </div>
595
+ </div>
596
+ )}
597
+ {messages.map((msg, i) => {
598
+ const isStreamingEmpty = streaming && i === messages.length - 1 && msg.role === "assistant" && msg.content === "" && !msg.toolCalls?.length;
599
+ if (isStreamingEmpty) return <TypingBubble key={msg.id} agentAvatar={agent.avatar} />;
600
+ return (
601
+ <MessageBubble key={msg.id} message={msg} agentName={agent.name} agentAvatar={agent.avatar}
602
+ showTimestamp={shouldShowTimestamp(i)}
603
+ onRetry={msg.role === "assistant" ? handleRetry : undefined}
604
+ onFork={msg.role === "assistant" ? handleFork : undefined}
605
+ onSpeak={msg.role === "assistant" ? (text) => setPendingTTS(text) : undefined} />
606
+ );
607
+ })}
608
+ <div ref={bottomRef} className="h-2" />
609
+ </div>
610
+ </div>
611
+
612
+ {/* 滚动到底部按钮 - 科技感设计 */}
613
+ {showScrollBtn && (
614
+ <button
615
+ type="button"
616
+ onClick={() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); setShowScrollBtn(false); }}
617
+ className="absolute bottom-24 right-6 z-10 h-11 w-11 rounded-full glass-effect border border-cyan-500/50 flex items-center justify-center hover:scale-110 transition-all neon-glow shadow-xl"
618
+ >
619
+ <ChevronDown className="h-5 w-5 text-cyan-400" />
620
+ </button>
621
+ )}
622
+
623
+ {/* FIX Bug2: pass isStreaming + model override */}
624
+ <InputBar onSend={sendMessage} disabled={streaming} isStreaming={streaming}
625
+ voiceEnabled={agent.voice.enabled} voiceSpeed={agent.voice.speed}
626
+ agentLanguage={agent.persona.language}
627
+ ttsProvider={agent.voice.ttsProvider}
628
+ ttsModel={agent.voice.ttsModel}
629
+ voiceId={agent.voice.voiceId}
630
+ pendingReply={pendingTTS}
631
+ onSpeakDone={() => setPendingTTS(undefined)} installedSkills={installedSkills}
632
+ onNewSession={newSession} onClearMessages={() => setMessages([])}
633
+ currentModel={sessionModel ?? { provider: agent.model.provider, modelId: agent.model.modelId }}
634
+ onModelChange={(provider, modelId) => setSessionModel({ provider, modelId })} />
635
+ </div>
636
+
637
+ {/* ── 信息面板 - 科技感设计 ──────────────────────────────────────────── */}
638
+ {showInfoPanel && (
639
+ <div className="w-[320px] shrink-0 border-l border-purple-500/20 glass-effect flex flex-col overflow-hidden">
640
+ <div className="px-4 py-3 border-b border-purple-500/20 flex items-center justify-between">
641
+ <span className="text-[14px] font-bold bg-gradient-to-r from-cyan-400 to-purple-400 bg-clip-text text-transparent">
642
+ 数字人信息
643
+ </span>
644
+ <button
645
+ onClick={() => setShowInfoPanel(false)}
646
+ className="h-8 w-8 rounded-lg hover:bg-white/10 flex items-center justify-center transition-all hover:scale-110"
647
+ >
648
+ <ChevronLeft className="h-4 w-4 text-cyan-400" />
649
+ </button>
650
+ </div>
651
+
652
+ <ScrollArea className="flex-1">
653
+ <div className="p-4 space-y-4">
654
+ {/* 数字人基本信息 */}
655
+ <div className="flex items-start gap-3 p-3 rounded-xl glass-effect border border-purple-500/30 neon-glow-purple">
656
+ <div className="h-12 w-12 rounded-xl bg-gradient-to-br from-purple-600/30 to-cyan-500/30 flex items-center justify-center text-2xl shrink-0 border border-purple-500/30">
657
+ {agent.avatar}
658
+ </div>
659
+ <div className="flex-1 min-w-0">
660
+ <p className="font-bold text-[13px] bg-gradient-to-r from-cyan-400 to-purple-400 bg-clip-text text-transparent">
661
+ {agent.name}
662
+ </p>
663
+ <p className="text-[11px] text-gray-400 mt-1 leading-relaxed">
664
+ {agent.description || "暂无描述"}
665
+ </p>
666
+ </div>
667
+ </div>
668
+
669
+ {/* 当前模型 */}
670
+ <div className="space-y-2">
671
+ <p className="text-[11px] font-bold text-cyan-400 uppercase tracking-wide">
672
+ 当前模型
673
+ </p>
674
+ <div className="rounded-xl glass-effect border border-cyan-500/30 p-3">
675
+ <p className="text-[12px] font-mono font-bold text-cyan-300">
676
+ {agent.model.provider} / {agent.model.modelId}
677
+ </p>
678
+ <div className="flex gap-2 mt-2 flex-wrap">
679
+ {agent.model.thinkingLevel !== "off" && (
680
+ <span className="text-[10px] px-2 py-1 rounded-md glass-effect border border-purple-500/30 text-purple-300 font-medium">
681
+ 🧠 {agent.model.thinkingLevel}
682
+ </span>
683
+ )}
684
+ <span className="text-[10px] px-2 py-1 rounded-md glass-effect border border-cyan-500/30 text-cyan-300 font-medium">
685
+ temp {agent.model.temperature}
686
+ </span>
687
+ </div>
688
+ </div>
689
+ </div>
690
+
691
+ {/* 会话用量统计 */}
692
+ {sessionStats && (
693
+ <div className="space-y-2">
694
+ <p className="text-[11px] font-bold text-purple-400 uppercase tracking-wide">
695
+ 本次会话用量
696
+ </p>
697
+ <div className="rounded-xl glass-effect border border-purple-500/30 p-3 space-y-2">
698
+ <div className="flex justify-between text-[11px] items-center">
699
+ <span className="text-gray-400">输入 tokens</span>
700
+ <span className="font-mono font-bold text-cyan-300">{sessionStats.tokens.input.toLocaleString()}</span>
701
+ </div>
702
+ <div className="flex justify-between text-[11px] items-center">
703
+ <span className="text-gray-400">输出 tokens</span>
704
+ <span className="font-mono font-bold text-purple-300">{sessionStats.tokens.output.toLocaleString()}</span>
705
+ </div>
706
+ {sessionStats.cost > 0 && (
707
+ <div className="flex justify-between text-[11px] items-center pt-2 border-t border-white/10">
708
+ <span className="text-gray-400">费用</span>
709
+ <span className="font-mono font-bold text-green-300">${sessionStats.cost.toFixed(4)}</span>
710
+ </div>
711
+ )}
712
+ {sessionStats.contextUsage && (
713
+ <div className="space-y-1.5 pt-2 border-t border-white/10">
714
+ <div className="flex justify-between text-[11px] items-center">
715
+ <span className="text-gray-400">Context 用量</span>
716
+ <span className={cn("font-mono font-bold", ctxPct > 80 ? "text-red-400" : "text-cyan-300")}>
717
+ {ctxPct}%
718
+ </span>
719
+ </div>
720
+ <div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
721
+ <div className={cn("h-full rounded-full transition-all duration-500", ctxColor)} style={{ width: `${ctxPct}%` }} />
722
+ </div>
723
+ </div>
724
+ )}
725
+ </div>
726
+ </div>
727
+ )}
728
+
729
+ {/* 已开启工具 */}
730
+ {Object.entries(agent.tools).some(([, v]) => v) && (
731
+ <div className="space-y-2">
732
+ <p className="text-[11px] font-bold text-green-400 uppercase tracking-wide">
733
+ 已开启工具
734
+ </p>
735
+ <div className="flex flex-wrap gap-2">
736
+ {Object.entries(agent.tools).filter(([, v]) => v).map(([k]) => (
737
+ <span
738
+ key={k}
739
+ className="text-[11px] px-3 py-1 rounded-lg glass-effect border border-green-500/30 text-green-300 font-medium"
740
+ >
741
+ {k}
742
+ </span>
743
+ ))}
744
+ </div>
745
+ </div>
746
+ )}
747
+
748
+ {/* Skills列表 */}
749
+ <div className="space-y-2">
750
+ <div className="flex items-center justify-between">
751
+ <p className="text-[11px] font-bold text-cyan-400 uppercase tracking-wide">
752
+ Skills <span className="normal-case font-normal text-[10px] text-gray-500">(本次会话)</span>
753
+ </p>
754
+ {sessionSkills !== null && (
755
+ <button
756
+ type="button"
757
+ onClick={() => setSessionSkills(null)}
758
+ className="text-[10px] text-purple-400 hover:text-purple-300 font-medium transition-colors"
759
+ >
760
+ 重置
761
+ </button>
762
+ )}
763
+ </div>
764
+ {installedSkills.length === 0 ? (
765
+ <div className="text-center py-8 px-4 rounded-xl glass-effect border border-dashed border-purple-500/30">
766
+ <Zap className="h-8 w-8 mx-auto mb-2 text-purple-400" />
767
+ <p className="text-[11px] text-gray-400">还没有安装任何 Skill</p>
768
+ </div>
769
+ ) : (
770
+ <div className="space-y-2">
771
+ {installedSkills.map((skill) => {
772
+ const enabled = effectiveSkills.includes(skill.name);
773
+ return (
774
+ <button
775
+ key={skill.name}
776
+ type="button"
777
+ onClick={() => toggleSessionSkill(skill.name)}
778
+ className={cn(
779
+ "w-full flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-all border",
780
+ enabled
781
+ ? "glass-effect border-purple-500/50 neon-glow-purple"
782
+ : "border-white/10 hover:border-purple-500/30 hover:bg-white/5"
783
+ )}
784
+ >
785
+ <div
786
+ className={cn(
787
+ "h-5 w-5 rounded-lg shrink-0 flex items-center justify-center transition-all border",
788
+ enabled
789
+ ? "bg-gradient-to-br from-purple-600 to-cyan-500 border-purple-500/50"
790
+ : "bg-white/10 border-white/20"
791
+ )}
792
+ >
793
+ {enabled && <Check className="h-3 w-3 text-white stroke-[3]" />}
794
+ </div>
795
+ <div className="min-w-0 flex-1">
796
+ <p className={cn("text-[11px] font-mono font-bold truncate", enabled ? "text-cyan-400" : "text-gray-300")}>
797
+ {skill.name}
798
+ </p>
799
+ {skill.description && (
800
+ <p className="text-[10px] text-gray-500 truncate mt-0.5">
801
+ {skill.description}
802
+ </p>
803
+ )}
804
+ </div>
805
+ </button>
806
+ );
807
+ })}
808
+ </div>
809
+ )}
810
+ </div>
811
+
812
+ {/* 人设提示词 */}
813
+ {agent.systemPrompt && (
814
+ <div className="space-y-2">
815
+ <p className="text-[11px] font-bold text-purple-400 uppercase tracking-wide">
816
+ 人设提示词
817
+ </p>
818
+ <div className="rounded-xl glass-effect border border-purple-500/30 p-3">
819
+ <p className="text-[11px] text-gray-400 leading-relaxed line-clamp-6 whitespace-pre-wrap">
820
+ {agent.systemPrompt}
821
+ </p>
822
+ </div>
823
+ </div>
824
+ )}
825
+ </div>
826
+ </ScrollArea>
827
+ </div>
828
+ )}
829
+ </div>
830
+ </div>
831
+ </div>
832
+ );
833
+ }