@jiangxiaosheng/digital-agent-platform 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/eslint.config.mjs +20 -0
- package/messages/en.json +184 -0
- package/messages/zh.json +184 -0
- package/middleware.ts +9 -0
- package/next-env.d.ts +5 -0
- package/next.config.ts +16 -0
- package/package.json +59 -0
- package/postcss.config.mjs +5 -0
- package/src/app/[locale]/agents/[id]/chat/page.tsx +833 -0
- package/src/app/[locale]/agents/[id]/edit/page.tsx +58 -0
- package/src/app/[locale]/agents/new/page.tsx +52 -0
- package/src/app/[locale]/agents/page.tsx +122 -0
- package/src/app/[locale]/knowledge/page.tsx +305 -0
- package/src/app/[locale]/layout.tsx +46 -0
- package/src/app/[locale]/page.tsx +6 -0
- package/src/app/[locale]/settings/page.tsx +1204 -0
- package/src/app/api/agents/[id]/route.ts +27 -0
- package/src/app/api/agents/route.ts +27 -0
- package/src/app/api/auth-config/test/route.ts +69 -0
- package/src/app/api/bash-output/route.ts +57 -0
- package/src/app/api/chat/[agentId]/route.ts +164 -0
- package/src/app/api/env-vars/[key]/route.ts +22 -0
- package/src/app/api/env-vars/route.ts +26 -0
- package/src/app/api/env-vars/scan/route.ts +66 -0
- package/src/app/api/file-serve/route.ts +58 -0
- package/src/app/api/fs/browse/route.ts +47 -0
- package/src/app/api/knowledge/[id]/documents/[docId]/route.ts +22 -0
- package/src/app/api/knowledge/[id]/route.ts +21 -0
- package/src/app/api/knowledge/[id]/upload/route.ts +90 -0
- package/src/app/api/knowledge/route.ts +15 -0
- package/src/app/api/knowledge/search/route.ts +26 -0
- package/src/app/api/models/route.ts +160 -0
- package/src/app/api/models-config/route.ts +41 -0
- package/src/app/api/models-config/test/route.ts +84 -0
- package/src/app/api/sessions/[agentId]/[sessionId]/route.ts +12 -0
- package/src/app/api/sessions/[agentId]/route.ts +19 -0
- package/src/app/api/sessions/history/route.ts +166 -0
- package/src/app/api/settings/route.ts +45 -0
- package/src/app/api/skills/[name]/route.ts +40 -0
- package/src/app/api/skills/install/route.ts +35 -0
- package/src/app/api/skills/route.ts +54 -0
- package/src/app/api/skills/search/route.ts +60 -0
- package/src/app/api/tts/route.ts +235 -0
- package/src/app/api/voice-provider/route.ts +98 -0
- package/src/app/api/workspace-files/route.ts +151 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +13 -0
- package/src/app/page.tsx +6 -0
- package/src/components/agents/agent-form.tsx +457 -0
- package/src/components/agents/model-selector.tsx +290 -0
- package/src/components/agents/skills-manager.tsx +347 -0
- package/src/components/chat/input-bar.tsx +561 -0
- package/src/components/chat/message-bubble.tsx +395 -0
- package/src/components/layout/sidebar.tsx +304 -0
- package/src/components/providers.tsx +19 -0
- package/src/components/ui/badge.tsx +26 -0
- package/src/components/ui/button.tsx +43 -0
- package/src/components/ui/card.tsx +46 -0
- package/src/components/ui/dialog.tsx +74 -0
- package/src/components/ui/directory-picker.tsx +185 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/label.tsx +16 -0
- package/src/components/ui/scroll-area.tsx +41 -0
- package/src/components/ui/select.tsx +93 -0
- package/src/components/ui/slider.tsx +35 -0
- package/src/components/ui/switch.tsx +35 -0
- package/src/components/ui/tabs.tsx +48 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toaster.tsx +50 -0
- package/src/hooks/use-toast.ts +43 -0
- package/src/i18n/request.ts +13 -0
- package/src/i18n/routing.ts +7 -0
- package/src/lib/agent-factory.ts +200 -0
- package/src/lib/agents/store.ts +95 -0
- package/src/lib/db/client.ts +25 -0
- package/src/lib/db/schema.ts +66 -0
- package/src/lib/env-vars.ts +33 -0
- package/src/lib/knowledge/chunker.ts +34 -0
- package/src/lib/knowledge/embedder.ts +48 -0
- package/src/lib/knowledge/retriever.ts +94 -0
- package/src/lib/knowledge/store.ts +74 -0
- package/src/lib/models-config.ts +55 -0
- package/src/lib/settings.ts +33 -0
- package/src/lib/skills.ts +79 -0
- package/src/lib/system-prompt.ts +52 -0
- package/src/lib/tool-builder.ts +19 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/voice-providers-config.ts +71 -0
- package/src/middleware.ts +9 -0
- package/src/types/agent.ts +124 -0
- package/src/types/knowledge.ts +39 -0
- package/src/types/settings.ts +31 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,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
|
+
}
|