chore: 前端代码优化 - ESLint 配置、Prettier 格式化、加载骨架屏、React 组件规范
- 更新 ESLint 配置添加 react-hooks 规则 - 运行 Prettier 格式化所有前端文件 - 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏 - 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement) - 修复 useRef 渲染期间访问问题(改用 useMemo) - 修复 effect 中 setState 同步调用问题 - 新增 global-not-found.tsx 404 页面 - 修复 knowledge 页面引号转义问题
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -56,7 +57,10 @@ function parseToolCalls(content: string): { cleanContent: string; tools: ToolCal
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
if (tool) tool.status = "done";
|
||||
}
|
||||
cleanContent = cleanContent.replace(/\[工具调用:\s*.+?\]/g, "").replace(/\[工具结果:\s*.+?\]/g, "").trim();
|
||||
cleanContent = cleanContent
|
||||
.replace(/\[工具调用:\s*.+?\]/g, "")
|
||||
.replace(/\[工具结果:\s*.+?\]/g, "")
|
||||
.trim();
|
||||
return { cleanContent, tools };
|
||||
}
|
||||
|
||||
@@ -96,10 +100,16 @@ const AgentMessage = memo(function AgentMessage({
|
||||
<div
|
||||
key={tool.name}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs ${
|
||||
tool.status === "done" ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"
|
||||
tool.status === "done"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-amber-50 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{tool.status === "done" ? <CheckCircle2 className="h-3 w-3" /> : <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{tool.status === "done" ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{tool.name}
|
||||
</div>
|
||||
))}
|
||||
@@ -130,7 +140,11 @@ interface AgentUIProps {
|
||||
export default function AgentUI({ app }: AgentUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>(
|
||||
app.welcome_message && !conversationId
|
||||
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
|
||||
: [],
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
@@ -153,7 +167,9 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
try {
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch { return {}; }
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [app.app_config]);
|
||||
|
||||
const tools: string[] = appConfig.tools || [];
|
||||
@@ -171,39 +187,43 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]);
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (app.welcome_message && messages.length === 0 && !conversationId) {
|
||||
setMessages([{ id: "welcome", role: "assistant", content: app.welcome_message }]);
|
||||
}
|
||||
}, [app.welcome_message, messages.length, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadConversation = useCallback(async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(`/api/v1/apps/${app.id}/conversations/${convId}/messages`);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [app.id]);
|
||||
const loadConversation = useCallback(
|
||||
async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||
);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[app.id],
|
||||
);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const suggestedPrompts = useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch { return []; }
|
||||
})()
|
||||
).current;
|
||||
const suggestedPrompts = useMemo(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [app.suggested_prompts]);
|
||||
|
||||
const copyText = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
@@ -275,13 +295,13 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
@@ -291,8 +311,8 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
@@ -300,9 +320,15 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
}
|
||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
}, [sendMessage]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
@@ -324,15 +350,20 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
||||
}, [conversations]);
|
||||
|
||||
const confirmDeleteSingle = useCallback(async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||
if (conversationId === convId) startNewConversation();
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("对话已删除");
|
||||
} catch { toast.error("删除失败"); }
|
||||
setDeleteTarget(null);
|
||||
}, [app.id, conversationId, startNewConversation, queryClient]);
|
||||
const confirmDeleteSingle = useCallback(
|
||||
async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||
if (conversationId === convId) startNewConversation();
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("对话已删除");
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[app.id, conversationId, startNewConversation, queryClient],
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
@@ -345,18 +376,17 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success(`已删除 ${selectedIds.size} 个对话`);
|
||||
} catch { toast.error("批量删除失败"); }
|
||||
} catch {
|
||||
toast.error("批量删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const startRename = useCallback((convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
@@ -364,10 +394,9 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||
name: editingName.trim(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
@@ -379,10 +408,7 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
@@ -419,12 +445,23 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
|
||||
<div
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||||
>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-1.5 text-muted-foreground" onClick={() => router.push("/store")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-1.5 text-muted-foreground"
|
||||
onClick={() => router.push("/store")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button onClick={startNewConversation} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
||||
<Button
|
||||
onClick={startNewConversation}
|
||||
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 新对话
|
||||
</Button>
|
||||
</div>
|
||||
@@ -453,7 +490,14 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={selectAll}>全选</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -463,12 +507,25 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => setSelectMode(true)}
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||
</Button>
|
||||
)}
|
||||
@@ -485,7 +542,11 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||
{selectedIds.has(conv.id) ? <CheckSquare className="h-3.5 w-3.5 text-primary" /> : <Square className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{editingConvId === conv.id ? (
|
||||
@@ -554,12 +615,18 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">{app.description}</p>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||
{messages.length > 1 && (
|
||||
@@ -586,7 +653,16 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{suggestedPrompts.map((prompt: string, i: number) => (
|
||||
<Button key={i} variant="outline" size="sm" className="text-xs" onClick={() => { setInput(prompt); textareaRef.current?.focus(); }}>
|
||||
<Button
|
||||
key={i}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
setInput(prompt);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
@@ -608,8 +684,16 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button onClick={sendMessage} disabled={!input.trim() || isStreaming} className="shrink-0 gap-1.5">
|
||||
{isStreaming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
{isStreaming ? "思考中" : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user