解决 artifact 预览组件冲突:mermaid 渲染(dhk-1) 与 markdown 锚点跳转(master) 取并集;package-lock.json 按 master 删除(项目用 pnpm)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
642 lines
24 KiB
TypeScript
642 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowLeftIcon,
|
|
CheckCircleIcon,
|
|
InfoIcon,
|
|
SaveIcon,
|
|
SearchIcon,
|
|
} from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
PromptInput,
|
|
PromptInputFooter,
|
|
PromptInputSubmit,
|
|
PromptInputTextarea,
|
|
} from "@/components/ai-elements/prompt-input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "@/components/ui/resizable";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
|
import {
|
|
MessageList,
|
|
type SetupAgentApprovalPayload,
|
|
} from "@/components/workspace/messages";
|
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
|
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
|
import { getAPIClient } from "@/core/api";
|
|
import type { Agent } from "@/core/agents";
|
|
import {
|
|
AgentNameCheckError,
|
|
checkAgentName,
|
|
createAgent,
|
|
updateAgent,
|
|
} from "@/core/agents/api";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { useModels } from "@/core/models/hooks";
|
|
import type { Skill } from "@/core/skills";
|
|
import { useSkills } from "@/core/skills/hooks";
|
|
import { useThreadStream } from "@/core/threads/hooks";
|
|
import { uuid } from "@/core/utils/uuid";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
|
|
|
function getCreateAgentErrorMessage(
|
|
error: unknown,
|
|
networkErrorMessage: string,
|
|
fallbackMessage: string,
|
|
) {
|
|
if (error instanceof TypeError && error.message === "Failed to fetch") {
|
|
return networkErrorMessage;
|
|
}
|
|
if (error instanceof Error && error.message) {
|
|
return error.message;
|
|
}
|
|
return fallbackMessage;
|
|
}
|
|
|
|
export default function NewAgentPage() {
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const threadId = useMemo(() => uuid(), []);
|
|
|
|
const [nameInput, setNameInput] = useState("");
|
|
const [descriptionInput, setDescriptionInput] = useState("");
|
|
const [soulInput, setSoulInput] = useState("");
|
|
const [modelInput, setModelInput] = useState<string>("");
|
|
const [chatModelInput, setChatModelInput] = useState<string>("");
|
|
const [formError, setFormError] = useState("");
|
|
const [agentId, setAgentId] = useState("");
|
|
const [agent, setAgent] = useState<Agent | null>(null);
|
|
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
|
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
|
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
|
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
|
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
|
const [skillSearch, setSkillSearch] = useState("");
|
|
const [threadPrepared, setThreadPrepared] = useState(false);
|
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
|
const { models, isLoading: isModelsLoading } = useModels();
|
|
|
|
const enabledSkills = useMemo(
|
|
() => skills.filter((skill) => skill.enabled),
|
|
[skills],
|
|
);
|
|
|
|
const filteredSkills = useMemo(() => {
|
|
const q = skillSearch.trim().toLowerCase();
|
|
if (!q) return enabledSkills;
|
|
return enabledSkills.filter(
|
|
(skill) =>
|
|
skill.name.toLowerCase().includes(q) ||
|
|
(skill.description ?? "").toLowerCase().includes(q),
|
|
);
|
|
}, [enabledSkills, skillSearch]);
|
|
|
|
useEffect(() => {
|
|
if (isSkillSelectionTouched) {
|
|
return;
|
|
}
|
|
setSelectedSkills(
|
|
enabledSkills
|
|
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
|
|
.map((skill) => skill.name),
|
|
);
|
|
}, [enabledSkills, isSkillSelectionTouched]);
|
|
|
|
// Seed both model selectors with the first available model the first time
|
|
// models load. The user can still change either independently afterwards.
|
|
useEffect(() => {
|
|
if (models.length === 0) {
|
|
return;
|
|
}
|
|
const first = models[0]!.name;
|
|
setModelInput((prev) =>
|
|
prev && models.some((m) => m.name === prev) ? prev : first,
|
|
);
|
|
setChatModelInput((prev) =>
|
|
prev && models.some((m) => m.name === prev) ? prev : first,
|
|
);
|
|
}, [models]);
|
|
|
|
const resolvedModelInput = useMemo(() => {
|
|
if (models.length === 0) {
|
|
return "";
|
|
}
|
|
if (modelInput && models.some((m) => m.name === modelInput)) {
|
|
return modelInput;
|
|
}
|
|
return models[0]!.name;
|
|
}, [modelInput, models]);
|
|
|
|
const resolvedChatModelInput = useMemo(() => {
|
|
if (models.length === 0) {
|
|
return "";
|
|
}
|
|
if (chatModelInput && models.some((m) => m.name === chatModelInput)) {
|
|
return chatModelInput;
|
|
}
|
|
return models[0]!.name;
|
|
}, [chatModelInput, models]);
|
|
|
|
const [thread, sendMessage] = useThreadStream({
|
|
threadId,
|
|
assistantId: agentId || undefined,
|
|
context: {
|
|
mode: "flash",
|
|
is_bootstrap: true,
|
|
model_name: chatModelInput || undefined,
|
|
},
|
|
onToolEnd() {
|
|
// setup_agent never actually executes in this flow 鈥?it gets
|
|
// intercepted by SetupAgentApprovalMiddleware and the form is filled
|
|
// from the approval ToolMessage payload (handleApproveSetupAgent).
|
|
// No agent record is fetched/refreshed during chat.
|
|
},
|
|
});
|
|
|
|
const ensureThreadExists = useCallback(async () => {
|
|
if (threadPrepared) {
|
|
return;
|
|
}
|
|
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
|
|
setThreadPrepared(true);
|
|
}, [threadId, threadPrepared]);
|
|
|
|
const validateForm = useCallback(() => {
|
|
// Name is intentionally NOT required here so the user can start chatting
|
|
// immediately. If they leave it blank, ensureAgent() generates a placeholder
|
|
// and writes it back into the input so they can rename it later.
|
|
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
|
|
setFormError("请至少选择一个技能");
|
|
return null;
|
|
}
|
|
setFormError("");
|
|
return nameInput.trim();
|
|
}, [enabledSkills.length, nameInput, selectedSkills.length]);
|
|
|
|
const ensureAgent = useCallback(async () => {
|
|
const provided = validateForm();
|
|
if (provided === null) {
|
|
return null;
|
|
}
|
|
let name = provided;
|
|
if (!name) {
|
|
// Names are display-only and may collide, so a timestamped placeholder
|
|
// is safe to send to the backend just to satisfy the required-field
|
|
// validation. Intentionally NOT written back into the visible input 鈥?
|
|
// the user wants the name box to stay blank while they chat, and we
|
|
// refill it later (with the model's proposed name) on setup_agent
|
|
// approval.
|
|
name = `未命名智能体-${Date.now().toString(36)}`;
|
|
}
|
|
|
|
const modelOverride = modelInput.trim() || null;
|
|
|
|
if (agentId) {
|
|
setIsSavingDraft(true);
|
|
try {
|
|
const updated = await updateAgent(agentId, {
|
|
name,
|
|
description: descriptionInput,
|
|
soul: soulInput,
|
|
skills: selectedSkills,
|
|
model: modelOverride,
|
|
});
|
|
setAgent(updated);
|
|
return updated;
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : String(error));
|
|
return null;
|
|
} finally {
|
|
setIsSavingDraft(false);
|
|
}
|
|
}
|
|
|
|
setIsCreatingAgent(true);
|
|
try {
|
|
await checkAgentName(name);
|
|
const created = await createAgent({
|
|
name,
|
|
description: descriptionInput,
|
|
soul: soulInput,
|
|
skills: selectedSkills,
|
|
model: modelOverride,
|
|
});
|
|
setAgentId(created.id);
|
|
setAgent(created);
|
|
return created;
|
|
} catch (error) {
|
|
if (
|
|
error instanceof AgentNameCheckError &&
|
|
error.reason === "backend_unreachable"
|
|
) {
|
|
setFormError(t.agents.nameStepNetworkError);
|
|
} else {
|
|
setFormError(
|
|
getCreateAgentErrorMessage(
|
|
error,
|
|
t.agents.nameStepNetworkError,
|
|
t.agents.nameStepCheckError,
|
|
),
|
|
);
|
|
}
|
|
return null;
|
|
} finally {
|
|
setIsCreatingAgent(false);
|
|
}
|
|
}, [
|
|
agentId,
|
|
descriptionInput,
|
|
modelInput,
|
|
selectedSkills,
|
|
soulInput,
|
|
t.agents.nameStepCheckError,
|
|
t.agents.nameStepNetworkError,
|
|
validateForm,
|
|
]);
|
|
|
|
const handleSaveAgent = useCallback(async () => {
|
|
const saved = await ensureAgent();
|
|
if (!saved) {
|
|
return;
|
|
}
|
|
toast.success(`智能体「${saved.name}」已保存`);
|
|
router.push("/page/workspace/agents");
|
|
}, [ensureAgent, router]);
|
|
|
|
const handleChatSubmit = useCallback(
|
|
async (text: string) => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed || thread.isLoading) {
|
|
return;
|
|
}
|
|
// Chat is purely a discussion channel. The agent record is only
|
|
// created when the user explicitly clicks Save (top-right). If the
|
|
// model proposes a config via setup_agent, our approval handler
|
|
// prefills the form locally 鈥?it does NOT persist anything.
|
|
await ensureThreadExists();
|
|
await sendMessage(threadId, { text: trimmed, files: [] });
|
|
},
|
|
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
|
|
);
|
|
|
|
const handleApproveSetupAgent = useCallback(
|
|
async (payload: SetupAgentApprovalPayload) => {
|
|
// Approving the model's setup_agent proposal **does not** persist
|
|
// anything 鈥?it only mirrors the proposal into the form so the user
|
|
// can review it. Creation/update happens later when the user clicks
|
|
// the top-right Save button.
|
|
const proposedName = payload.name?.trim() ?? "";
|
|
const newDescription = payload.description ?? "";
|
|
const newSoul = payload.soul ?? "";
|
|
const newSkills = payload.skills ?? [];
|
|
|
|
if (proposedName) {
|
|
setNameInput(proposedName);
|
|
}
|
|
setDescriptionInput(newDescription);
|
|
setSoulInput(newSoul);
|
|
setSelectedSkills(newSkills);
|
|
setIsSkillSelectionTouched(true);
|
|
toast.success("已套用模型建议,可继续编辑或点保存");
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleRejectSetupAgent = useCallback(
|
|
async () => {
|
|
await handleChatSubmit("我暂时不批准创建这个智能体配置,请继续帮我调整。");
|
|
},
|
|
[handleChatSubmit],
|
|
);
|
|
|
|
const toggleSkill = useCallback((skill: Skill) => {
|
|
setIsSkillSelectionTouched(true);
|
|
setFormError("");
|
|
setSelectedSkills((prev) =>
|
|
prev.includes(skill.name)
|
|
? prev.filter((name) => name !== skill.name)
|
|
: [...prev, skill.name],
|
|
);
|
|
}, []);
|
|
|
|
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
|
|
|
|
return (
|
|
<ThreadContext.Provider value={{ thread }}>
|
|
<ArtifactsProvider>
|
|
<div className="flex size-full flex-col">
|
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => router.push("/page/workspace/agents")}
|
|
>
|
|
<ArrowLeftIcon className="h-4 w-4" />
|
|
</Button>
|
|
<h1 className="text-sm font-semibold">创建智能体</h1>
|
|
</div>
|
|
{agent ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => router.push(`/page/workspace/agents/${agent.id}/chats/new`)}
|
|
>
|
|
<CheckCircleIcon className="h-4 w-4" />
|
|
开始对话
|
|
</Button>
|
|
) : null}
|
|
</header>
|
|
|
|
<ResizablePanelGroup
|
|
id="new-agent-config-panels"
|
|
orientation="horizontal"
|
|
defaultLayout={{ chat: 62, config: 38 }}
|
|
className="min-h-0 flex-1"
|
|
>
|
|
<ResizablePanel
|
|
id="chat"
|
|
defaultSize={62}
|
|
minSize={40}
|
|
className="min-h-0"
|
|
>
|
|
<section className="flex size-full min-h-0 flex-col">
|
|
<div className="min-h-0 flex-1">
|
|
<MessageList
|
|
className="size-full"
|
|
threadId={threadId}
|
|
thread={thread}
|
|
onClarificationSubmit={handleChatSubmit}
|
|
onSetupAgentApprove={handleApproveSetupAgent}
|
|
onSetupAgentReject={handleRejectSetupAgent}
|
|
/>
|
|
</div>
|
|
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-xs">
|
|
当前对话模型
|
|
</span>
|
|
{isModelsLoading || models.length === 0 ? (
|
|
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
|
{isModelsLoading ? "加载中…" : "暂无模型"}
|
|
</div>
|
|
) : (
|
|
<Select
|
|
value={resolvedChatModelInput}
|
|
onValueChange={setChatModelInput}
|
|
disabled={thread.isLoading}
|
|
>
|
|
<SelectTrigger
|
|
className={cn(
|
|
"border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none",
|
|
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
|
|
"data-[placeholder]:text-muted-foreground",
|
|
"[&_svg]:dark:text-[#AACEE2]",
|
|
)}
|
|
>
|
|
<SelectValue placeholder="选择模型" />
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
position="popper"
|
|
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
|
|
>
|
|
{models.map((model) => (
|
|
<SelectItem
|
|
key={model.name}
|
|
value={model.name}
|
|
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
|
>
|
|
{model.display_name || model.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
<PromptInput
|
|
onSubmit={({ text }) => void handleChatSubmit(text)}
|
|
>
|
|
<PromptInputTextarea
|
|
placeholder="直接告诉模型你想要什么样的智能体,或回答模型的问题"
|
|
disabled={thread.isLoading}
|
|
/>
|
|
<PromptInputFooter className="justify-end">
|
|
<PromptInputSubmit disabled={thread.isLoading} />
|
|
</PromptInputFooter>
|
|
</PromptInput>
|
|
</div>
|
|
</section>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
<ResizablePanel
|
|
id="config"
|
|
defaultSize={38}
|
|
minSize={24}
|
|
maxSize={60}
|
|
className="min-h-0"
|
|
>
|
|
<aside className="bg-background size-full min-h-0 overflow-y-auto">
|
|
<div className="space-y-5 p-4">
|
|
<div>
|
|
<h2 className="text-sm font-semibold">智能体配置</h2>
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
|
这些内容会作为创建智能体时的初始配置。
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">名称</label>
|
|
<Input
|
|
placeholder="例如:合同审查智能体"
|
|
value={nameInput}
|
|
onChange={(event) => {
|
|
setNameInput(event.currentTarget.value);
|
|
setFormError("");
|
|
}}
|
|
className={cn(formError && "border-destructive")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">描述</label>
|
|
<Textarea
|
|
placeholder="简要说明这个智能体擅长什么"
|
|
value={descriptionInput}
|
|
onChange={(event) =>
|
|
setDescriptionInput(event.currentTarget.value)
|
|
}
|
|
className="min-h-24 resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">模型</label>
|
|
{isModelsLoading || models.length === 0 ? (
|
|
<div className="border-input bg-background text-muted-foreground flex h-9 w-full items-center rounded-md border px-3 text-sm shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
|
{isModelsLoading ? "加载模型…" : "暂无可用模型"}
|
|
</div>
|
|
) : (
|
|
<Select
|
|
value={resolvedModelInput}
|
|
onValueChange={setModelInput}
|
|
>
|
|
<SelectTrigger
|
|
className={cn(
|
|
"border-input bg-background h-9 w-full shadow-xs outline-none",
|
|
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
|
|
"data-[placeholder]:text-muted-foreground",
|
|
"[&_svg]:dark:text-[#AACEE2]",
|
|
)}
|
|
>
|
|
<SelectValue placeholder="选择模型" />
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
position="popper"
|
|
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
|
|
>
|
|
{models.map((model) => (
|
|
<SelectItem
|
|
key={model.name}
|
|
value={model.name}
|
|
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
|
>
|
|
{model.display_name || model.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<p className="text-muted-foreground text-xs">
|
|
该智能体被使用时调用的模型。
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">SOUL.md</label>
|
|
<Textarea
|
|
placeholder="写下智能体的人设、边界、工作方式和输出要求"
|
|
value={soulInput}
|
|
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
|
className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium">技能</label>
|
|
<span className="text-muted-foreground text-xs">
|
|
已选 {selectedSkills.length} 个
|
|
</span>
|
|
</div>
|
|
{enabledSkills.length > 0 && (
|
|
<div className="relative">
|
|
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
|
<Input
|
|
placeholder="搜索技能名称或描述…"
|
|
value={skillSearch}
|
|
onChange={(e) => setSkillSearch(e.currentTarget.value)}
|
|
className="h-8 pl-7 text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2 dark:border-[#1B477E] dark:bg-[#022248]">
|
|
{isSkillsLoading ? (
|
|
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
|
) : enabledSkills.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
当前没有可用技能。
|
|
</p>
|
|
) : filteredSkills.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
没有匹配的技能。
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{filteredSkills.map((skill) => {
|
|
const checked = selectedSkills.includes(skill.name);
|
|
return (
|
|
<div
|
|
key={skill.name}
|
|
className="flex w-full min-w-0 items-center"
|
|
>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={checked ? "default" : "outline"}
|
|
onClick={() => toggleSkill(skill)}
|
|
title={skill.description}
|
|
className={cn(
|
|
"h-8 min-w-0 flex-1 rounded-r-none",
|
|
!checked && "text-muted-foreground",
|
|
)}
|
|
>
|
|
<span className="truncate">{skill.name}</span>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant={checked ? "default" : "outline"}
|
|
onClick={() => setDetailSkillName(skill.name)}
|
|
title={t.settings.skills.viewDetailAction}
|
|
className="h-8 w-7 rounded-l-none border-l-0"
|
|
>
|
|
<InfoIcon className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<SkillDetailDialog
|
|
skillName={detailSkillName}
|
|
open={detailSkillName !== null}
|
|
onOpenChange={(o) => {
|
|
if (!o) setDetailSkillName(null);
|
|
}}
|
|
/>
|
|
|
|
{formError ? (
|
|
<p className="text-destructive text-sm">{formError}</p>
|
|
) : null}
|
|
|
|
<Button
|
|
className="w-full"
|
|
onClick={() => void handleSaveAgent()}
|
|
disabled={busy || isSkillsLoading}
|
|
>
|
|
<SaveIcon className="h-4 w-4" />
|
|
{agentId ? "保存智能体" : "创建智能体"}
|
|
</Button>
|
|
</div>
|
|
</aside>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
</ArtifactsProvider>
|
|
</ThreadContext.Provider>
|
|
);
|
|
}
|