@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,395 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import { useTranslations } from "next-intl";
5
+ import ReactMarkdown from "react-markdown";
6
+ import remarkGfm from "remark-gfm";
7
+ import rehypeHighlight from "rehype-highlight";
8
+ import {
9
+ Copy, Check, ChevronDown, ChevronRight, Wrench, Loader2,
10
+ RefreshCw, GitBranch, Brain, ExternalLink, Volume2,
11
+ } from "lucide-react";
12
+ import type { ChatMessage, ToolCall, AttachedImage } from "@/types/agent";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ // Detect absolute or ~-relative file paths with image/video extensions
16
+ const MEDIA_PATH_RE = /(?:\/(?:Users|home|tmp|var|root)\S+|~\/\S+)\.(?:png|jpg|jpeg|gif|webp|svg|mp4|webm|mov)/gi;
17
+ const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
18
+ const VIDEO_EXTS = new Set(["mp4", "webm", "mov"]);
19
+
20
+ function extractMediaPaths(content: string): { path: string; type: "image" | "video" }[] {
21
+ const matches = [...content.matchAll(MEDIA_PATH_RE)];
22
+ const seen = new Set<string>();
23
+ return matches
24
+ .map(m => m[0])
25
+ .filter(p => { if (seen.has(p)) return false; seen.add(p); return true; })
26
+ .map(p => {
27
+ const ext = p.split(".").pop()?.toLowerCase() ?? "";
28
+ return { path: p, type: IMAGE_EXTS.has(ext) ? "image" : VIDEO_EXTS.has(ext) ? "video" : "image" };
29
+ });
30
+ }
31
+
32
+ function serveUrl(filePath: string) {
33
+ return `/api/file-serve?path=${encodeURIComponent(filePath)}`;
34
+ }
35
+
36
+ interface MessageBubbleProps {
37
+ message: ChatMessage;
38
+ agentName?: string;
39
+ agentAvatar: string;
40
+ showTimestamp?: boolean;
41
+ onRetry?: (message: ChatMessage) => void;
42
+ onFork?: (message: ChatMessage) => void;
43
+ onSpeak?: (text: string) => void;
44
+ }
45
+
46
+ export function MessageBubble({
47
+ message, agentName, agentAvatar, showTimestamp, onRetry, onFork, onSpeak,
48
+ }: MessageBubbleProps) {
49
+ const t = useTranslations("chat");
50
+ const [copied, setCopied] = useState(false);
51
+ const [thinkingOpen, setThinkingOpen] = useState(false);
52
+ const isUser = message.role === "user";
53
+
54
+ // Detect image/video file paths in assistant messages
55
+ const mediaPaths = useMemo(
56
+ () => (!isUser && message.content ? extractMediaPaths(message.content) : []),
57
+ [isUser, message.content]
58
+ );
59
+
60
+ const handleCopy = () => {
61
+ navigator.clipboard.writeText(message.content);
62
+ setCopied(true);
63
+ setTimeout(() => setCopied(false), 2000);
64
+ };
65
+
66
+ return (
67
+ <div className="px-3">
68
+ {showTimestamp && (
69
+ <div className="flex justify-center my-2">
70
+ <span className="text-[11px] text-gray-500 neo-card bg-white dark:bg-slate-800/50 dark:backdrop-blur-sm rounded-full px-3 py-1 dark:border-slate-700/50">
71
+ {formatTime(message.createdAt)}
72
+ </span>
73
+ </div>
74
+ )}
75
+
76
+ <div className={cn("group flex items-end gap-2 mb-1", isUser ? "flex-row-reverse" : "flex-row")}>
77
+ {/* Avatar */}
78
+ <div className={cn(
79
+ "flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-lg shadow-lg transition-all duration-200 border",
80
+ isUser
81
+ ? "neo-card bg-gradient-to-br from-blue-500 to-indigo-500 dark:from-cyan-600 dark:to-blue-600 text-white text-sm font-bold border-blue-400/50 dark:border-cyan-500/50 group-hover:scale-110 group-hover:shadow-blue-500/30 dark:group-hover:shadow-cyan-500/50"
82
+ : "neo-card bg-white dark:bg-slate-800 text-xl border-gray-200 dark:border-slate-700 group-hover:scale-110 dark:group-hover:shadow-purple-500/30",
83
+ )}>
84
+ {isUser ? "我" : agentAvatar}
85
+ </div>
86
+
87
+ <div className={cn("flex flex-col gap-1.5 max-w-[75%]", isUser ? "items-end" : "items-start")}>
88
+ {!isUser && agentName && (
89
+ <span className="text-xs text-gray-500 px-1">{agentName}</span>
90
+ )}
91
+
92
+ {/* Thinking Block */}
93
+ {!isUser && message.thinking && (
94
+ <button
95
+ type="button"
96
+ onClick={() => setThinkingOpen(!thinkingOpen)}
97
+ className="flex items-center gap-1.5 rounded-xl border neo-card border-purple-200 bg-purple-50 dark:border-purple-500/30 dark:bg-purple-900/20 dark:backdrop-blur-sm px-3 py-1.5 text-xs text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/30 dark:hover:border-purple-500/50 transition-all duration-200 w-full text-left group/think hover:scale-[1.02]"
98
+ >
99
+ <Brain className="h-3.5 w-3.5 shrink-0 transition-transform duration-200 group-hover/think:rotate-12" />
100
+ <span className="flex-1 font-medium">思考过程</span>
101
+ {thinkingOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
102
+ </button>
103
+ )}
104
+ {thinkingOpen && message.thinking && (
105
+ <div className="rounded-xl border neo-card-inset border-purple-200 bg-purple-50/50 dark:border-purple-500/30 dark:bg-purple-900/10 dark:backdrop-blur-sm px-3 py-2 text-xs text-purple-800 dark:text-purple-200 w-full">
106
+ <div className="prose prose-xs max-w-none dark:prose-invert whitespace-pre-wrap leading-relaxed font-mono">
107
+ {message.thinking}
108
+ </div>
109
+ </div>
110
+ )}
111
+
112
+ {/* Main bubble */}
113
+ <div className="relative">
114
+ {isUser ? (
115
+ <span className="absolute right-[-5px] bottom-3 w-0 h-0 border-t-[5px] border-t-transparent border-b-[5px] border-b-transparent border-l-[6px] border-l-blue-500 dark:border-l-cyan-600" />
116
+ ) : (
117
+ <span className="absolute left-[-5px] bottom-3 w-0 h-0 border-t-[5px] border-t-transparent border-b-[5px] border-b-transparent border-r-[6px] border-r-white dark:border-r-slate-800" />
118
+ )}
119
+ <div className={cn(
120
+ "group/msg relative rounded-xl text-[14px] leading-[1.6] shadow-lg overflow-hidden transition-all duration-200 border backdrop-blur-sm",
121
+ isUser
122
+ ? "bg-gradient-to-br from-blue-500 to-indigo-500 dark:from-cyan-600 dark:to-blue-600 text-white rounded-br-[3px] border-blue-400/50 dark:border-cyan-500/50 hover:shadow-blue-500/20 dark:hover:shadow-cyan-500/30"
123
+ : "neo-card bg-white dark:bg-slate-800/90 text-gray-800 dark:text-gray-100 rounded-bl-[3px] border-gray-200 dark:border-slate-700/50 dark:hover:border-slate-600",
124
+ )}>
125
+ {/* Image attachments */}
126
+ {isUser && message.images && message.images.length > 0 && (
127
+ <div className="flex flex-wrap gap-1.5 p-2 pb-0">
128
+ {message.images.map((img: AttachedImage) => (
129
+ // eslint-disable-next-line @next/next/no-img-element
130
+ <img key={img.id} src={img.previewUrl} alt={img.name}
131
+ className="h-32 max-w-[180px] object-cover rounded-md" />
132
+ ))}
133
+ </div>
134
+ )}
135
+ <div className="px-3.5 py-2.5">
136
+ {isUser ? (
137
+ message.content ? <p className="whitespace-pre-wrap break-words">{message.content}</p> : null
138
+ ) : (
139
+ <div className={cn(
140
+ "prose prose-sm max-w-none break-words",
141
+ "prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1",
142
+ "prose-code:bg-gray-100 dark:prose-code:bg-black/10 prose-code:rounded prose-code:px-1 prose-code:text-[13px]",
143
+ "prose-pre:bg-gray-100 dark:prose-pre:bg-black/30 prose-pre:rounded-md prose-pre:text-[13px]",
144
+ "dark:prose-invert",
145
+ )}>
146
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
147
+ {message.content}
148
+ </ReactMarkdown>
149
+ </div>
150
+ )}
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ {/* Inline media — images/videos detected in assistant message */}
156
+ {mediaPaths.length > 0 && (
157
+ <div className="w-full space-y-2 mt-1">
158
+ {mediaPaths.map(({ path: p, type }) => (
159
+ <div key={p} className="rounded-xl overflow-hidden border border-border/40 bg-black/5 dark:bg-white/5">
160
+ {type === "image" ? (
161
+ <div className="relative group/media">
162
+ {/* eslint-disable-next-line @next/next/no-img-element */}
163
+ <img
164
+ src={serveUrl(p)}
165
+ alt={p.split("/").pop()}
166
+ className="w-full max-h-[480px] object-contain"
167
+ loading="lazy"
168
+ />
169
+ <a
170
+ href={serveUrl(p)}
171
+ target="_blank"
172
+ rel="noopener noreferrer"
173
+ download={p.split("/").pop()}
174
+ className="absolute top-2 right-2 opacity-0 group-hover/media:opacity-100 transition-opacity bg-black/60 text-white rounded-lg px-2 py-1 text-xs flex items-center gap-1"
175
+ >
176
+ <ExternalLink className="h-3 w-3" />
177
+ 打开原图
178
+ </a>
179
+ <p className="px-2 py-1 text-[10px] text-muted-foreground font-mono truncate border-t border-border/30">
180
+ {p}
181
+ </p>
182
+ </div>
183
+ ) : (
184
+ <div>
185
+ <video
186
+ src={serveUrl(p)}
187
+ controls
188
+ className="w-full max-h-[360px]"
189
+ preload="metadata"
190
+ />
191
+ <p className="px-2 py-1 text-[10px] text-muted-foreground font-mono truncate border-t border-border/30">
192
+ {p}
193
+ </p>
194
+ </div>
195
+ )}
196
+ </div>
197
+ ))}
198
+ </div>
199
+ )}
200
+
201
+ {/* Tool calls */}
202
+ {message.toolCalls && message.toolCalls.length > 0 && (
203
+ <div className="w-full space-y-1">
204
+ {message.toolCalls.map((tc) => <ToolCallCard key={tc.id} toolCall={tc} />)}
205
+ </div>
206
+ )}
207
+
208
+ {/* Hover action bar */}
209
+ <div className={cn(
210
+ "flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200",
211
+ isUser ? "self-end" : "self-start",
212
+ )}>
213
+ {/* Copy */}
214
+ <button type="button" onClick={handleCopy}
215
+ className="flex items-center gap-1 text-[11px] neo-button text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:bg-cyan-500/20 dark:hover:text-cyan-400 px-2.5 py-1 rounded-lg border-transparent dark:hover:border-cyan-500/50 transition-all duration-200 hover:scale-105">
216
+ {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
217
+ {copied ? t("copied") : t("copyMessage")}
218
+ </button>
219
+ {/* Retry (assistant) */}
220
+ {!isUser && onRetry && (
221
+ <button type="button" onClick={() => onRetry(message)}
222
+ className="flex items-center gap-1 text-[11px] neo-button text-gray-600 hover:text-purple-600 dark:text-gray-400 dark:hover:bg-purple-500/20 dark:hover:text-purple-400 px-2.5 py-1 rounded-lg border-transparent dark:hover:border-purple-500/50 transition-all duration-200 hover:scale-105">
223
+ <RefreshCw className="h-3 w-3 transition-transform duration-200 hover:rotate-180" />重试
224
+ </button>
225
+ )}
226
+ {/* Fork (assistant) */}
227
+ {!isUser && onFork && (
228
+ <button type="button" onClick={() => onFork(message)}
229
+ className="flex items-center gap-1 text-[11px] neo-button text-gray-600 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-blue-500/20 dark:hover:text-blue-400 px-2.5 py-1 rounded-lg border-transparent dark:hover:border-blue-500/50 transition-all duration-200 hover:scale-105">
230
+ <GitBranch className="h-3 w-3" />分叉
231
+ </button>
232
+ )}
233
+ {/* Speak (assistant) */}
234
+ {!isUser && onSpeak && (
235
+ <button type="button" onClick={() => onSpeak(message.content)}
236
+ className="flex items-center gap-1 text-[11px] neo-button text-gray-600 hover:text-green-600 dark:text-gray-400 dark:hover:bg-green-500/20 dark:hover:text-green-400 px-2.5 py-1 rounded-lg border-transparent dark:hover:border-green-500/50 transition-all duration-200 hover:scale-105">
237
+ <Volume2 className="h-3 w-3" />朗读
238
+ </button>
239
+ )}
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ // Typing indicator
248
+ export function TypingBubble({ agentAvatar }: { agentAvatar: string }) {
249
+ return (
250
+ <div className="px-3">
251
+ <div className="flex items-end gap-2 mb-1">
252
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-white dark:bg-zinc-700 text-xl shadow-sm">
253
+ {agentAvatar}
254
+ </div>
255
+ <div className="relative">
256
+ <span className={cn("absolute left-[-5px] bottom-3 w-0 h-0 border-t-[5px] border-t-transparent border-b-[5px] border-b-transparent border-r-[6px] border-r-white dark:border-r-zinc-700")} />
257
+ <div className="bg-white dark:bg-zinc-700 rounded-lg rounded-bl-[3px] px-4 py-3 shadow-sm">
258
+ <div className="flex items-center gap-1.5">
259
+ {[0, 1, 2].map((i) => (
260
+ <span key={i} className="h-2 w-2 rounded-full bg-zinc-400 animate-bounce"
261
+ style={{ animationDelay: `${i * 150}ms` }} />
262
+ ))}
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ // Tool call card
272
+ function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
273
+ const t = useTranslations("chat");
274
+ const [open, setOpen] = useState(false);
275
+ const [fullOutput, setFullOutput] = useState<string | null>(null);
276
+ const [loadingFull, setLoadingFull] = useState(false);
277
+
278
+ const isBash = toolCall.name === "bash" || toolCall.name === "user_bash";
279
+ // For bash: input is the raw command string; for others: may be JSON
280
+ const displayInput = (() => {
281
+ if (!toolCall.input) return null;
282
+ if (isBash) return toolCall.input; // already extracted as command string
283
+ try {
284
+ const parsed = JSON.parse(toolCall.input);
285
+ if (typeof parsed === "string") return parsed;
286
+ return JSON.stringify(parsed, null, 2);
287
+ } catch { return toolCall.input; }
288
+ })();
289
+
290
+ const shownOutput = fullOutput ?? toolCall.output;
291
+
292
+ const loadFull = async () => {
293
+ if (!toolCall.fullOutputPath || loadingFull) return;
294
+ setLoadingFull(true);
295
+ try {
296
+ const res = await fetch(`/api/bash-output?path=${encodeURIComponent(toolCall.fullOutputPath)}`);
297
+ const data = await res.json() as { output?: string; error?: string };
298
+ setFullOutput(data.output ?? data.error ?? "加载失败");
299
+ } catch (e) {
300
+ setFullOutput(`加载失败: ${String(e)}`);
301
+ } finally {
302
+ setLoadingFull(false);
303
+ }
304
+ };
305
+
306
+ return (
307
+ <div className={cn(
308
+ "rounded-lg border text-[12px] overflow-hidden w-full",
309
+ isBash ? "bg-gray-100 dark:bg-zinc-950 border-gray-300 dark:border-zinc-700" : "bg-black/5 dark:bg-white/5",
310
+ )}>
311
+ {/* Header */}
312
+ <button type="button" onClick={() => setOpen(!open)}
313
+ className={cn(
314
+ "flex w-full items-center gap-2 px-3 py-2 transition-colors text-left",
315
+ isBash
316
+ ? "text-gray-700 hover:bg-gray-200 dark:text-zinc-300 dark:hover:bg-zinc-800"
317
+ : "hover:bg-black/5 dark:hover:bg-white/5",
318
+ )}>
319
+ {toolCall.isLoading
320
+ ? <Loader2 className="h-3 w-3 text-muted-foreground animate-spin shrink-0" />
321
+ : isBash
322
+ ? <span className="text-[10px] font-bold text-zinc-500 shrink-0 font-mono">$_</span>
323
+ : <Wrench className="h-3 w-3 text-muted-foreground shrink-0" />}
324
+
325
+ {/* For bash: show the command in header; for others: show tool name */}
326
+ {isBash && displayInput ? (
327
+ <span className="font-mono flex-1 text-left truncate text-zinc-200">{displayInput}</span>
328
+ ) : (
329
+ <span className="font-mono font-medium flex-1 text-left truncate">{toolCall.name}</span>
330
+ )}
331
+
332
+ {!toolCall.isLoading && (
333
+ <span className={cn("text-xs shrink-0", toolCall.isError ? "text-red-400" : "text-green-400")}>
334
+ {toolCall.isError ? "✗" : "✓"}
335
+ </span>
336
+ )}
337
+ {toolCall.isLoading && <span className="text-muted-foreground text-[11px] animate-pulse shrink-0">{t("thinking")}</span>}
338
+ {open ? <ChevronDown className="h-3 w-3 shrink-0 text-zinc-500" /> : <ChevronRight className="h-3 w-3 shrink-0 text-zinc-500" />}
339
+ </button>
340
+
341
+ {/* Expanded body */}
342
+ {open && (
343
+ <div className={cn("border-t space-y-0", isBash ? "border-zinc-700" : "border-border")}>
344
+ {/* For non-bash: show input args */}
345
+ {!isBash && displayInput && (
346
+ <div className="px-3 py-2 border-b border-border">
347
+ <p className="text-muted-foreground mb-1 text-[11px] uppercase tracking-wide">{t("toolCall")}</p>
348
+ <pre className="whitespace-pre-wrap break-all text-[12px]">{displayInput}</pre>
349
+ </div>
350
+ )}
351
+
352
+ {/* Output area */}
353
+ {shownOutput !== undefined && (
354
+ <div className={cn("px-3 py-2", isBash && "bg-zinc-950")}>
355
+ {!isBash && <p className="text-muted-foreground mb-1 text-[11px] uppercase tracking-wide">{t("toolResult")}</p>}
356
+ <pre className={cn(
357
+ "whitespace-pre-wrap break-all text-[12px] max-h-64 overflow-auto leading-relaxed",
358
+ isBash ? "text-zinc-200 font-mono" : "",
359
+ toolCall.isError ? "text-red-400" : "",
360
+ )}>{shownOutput}</pre>
361
+
362
+ {/* Load full output button (when output was truncated) */}
363
+ {toolCall.truncated && toolCall.fullOutputPath && !fullOutput && (
364
+ <button type="button" onClick={loadFull} disabled={loadingFull}
365
+ className="mt-2 flex items-center gap-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 transition-colors disabled:opacity-50">
366
+ {loadingFull
367
+ ? <Loader2 className="h-3 w-3 animate-spin" />
368
+ : <ChevronDown className="h-3 w-3" />}
369
+ {loadingFull ? "加载中…" : "输出已截断,点击加载完整内容"}
370
+ </button>
371
+ )}
372
+ </div>
373
+ )}
374
+
375
+ {/* No output yet */}
376
+ {!shownOutput && !toolCall.isLoading && (
377
+ <div className="px-3 py-2">
378
+ <span className="text-[11px] text-zinc-500 italic">(无输出)</span>
379
+ </div>
380
+ )}
381
+ </div>
382
+ )}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ function formatTime(date: Date): string {
388
+ const now = new Date(), d = new Date(date);
389
+ const diffDays = Math.floor((now.getTime() - d.getTime()) / 86400000);
390
+ const timeStr = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
391
+ if (diffDays === 0) return timeStr;
392
+ if (diffDays === 1) return `昨天 ${timeStr}`;
393
+ if (diffDays < 7) return `${["周日","周一","周二","周三","周四","周五","周六"][d.getDay()]} ${timeStr}`;
394
+ return d.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " " + timeStr;
395
+ }