Compare commits

...

5 Commits

83 changed files with 2844 additions and 1304 deletions

View File

@ -263,11 +263,11 @@ docker-logs-gateway:
# ========================================== # ==========================================
# Docker Publish Command # Docker Publish Command
# ========================================== # ==========================================
# Usage: make docker-publish VER=v220.20251202 SVC=frontend # Usage: make docker-publish VER=[version] SVC=[service name] [PUSH=1]
# Example: make docker-publish VER=v220.20251202 SVC=frontend # Example: make docker-publish VER=v2.0.20251202 SVC=frontend PUSH=0
docker-publish: docker-publish:
@if [ -z "$(VER)" ]; then \ @if [ -z "$(VER)" ]; then \
echo "✗ VER is required (e.g. v220.20251202)"; \ echo "✗ VER is required (e.g. v2.0.20251202)"; \
exit 1; \ exit 1; \
fi fi
@if [ -z "$(SVC)" ]; then \ @if [ -z "$(SVC)" ]; then \
@ -293,9 +293,13 @@ docker-publish:
echo "✗ Docker build failed"; \ echo "✗ Docker build failed"; \
exit 1; \ exit 1; \
fi; \ fi; \
docker push $$IMAGE; \ if [ "$(PUSH)" = "0" ]; then \
if [ $$? -ne 0 ]; then \ echo "✓ Docker image $$IMAGE built successfully (not pushed)"; \
echo "✗ Docker push failed"; \ else \
exit 1; \ docker push $$IMAGE; \
fi; \ if [ $$? -ne 0 ]; then \
echo "✓ Docker image $$IMAGE built and pushed successfully" echo "✗ Docker push failed"; \
exit 1; \
fi; \
echo "✓ Docker image $$IMAGE built and pushed successfully"; \
fi

View File

@ -16,15 +16,15 @@ http {
# Upstream servers (using Docker service names for Docker Compose) # Upstream servers (using Docker service names for Docker Compose)
upstream gateway { upstream gateway {
server gateway:8001; server localhost:8001;
} }
upstream langgraph { upstream langgraph {
server langgraph:2024; server localhost:2024;
} }
upstream frontend { upstream frontend {
server frontend:3000; server localhost:3000;
} }
server { server {

View File

@ -1,3 +1,67 @@
{ {
"window.title": "${activeEditorShort}${separator}${separator}deer-flow/frontend" "window.title": "${activeEditorShort}${separator}${separator}deer-flow/frontend",
"todo-tree.regex.regex": "((%|#|//|<!--|\\{/\\*|^\\s*\\*)\\s*($TAGS)|^\\s*- \\[ \\])",
"todo-tree.general.tags": [
"TODO:",
"BUG:",
"TAG:",
"DONE:",
"MARK:",
"TEST:",
"XXX:"
],
"todo-tree.regex.regexCaseSensitive": false,
"todo-tree.highlights.defaultHighlight": {
"foreground": "#000000",
"background": "#fff700",
"icon": "check",
"rulerColour": "#fff700",
"type": "tag",
"iconColour": "#fff700"
},
"todo-tree.highlights.customHighlight": {
"TODO:": {
"icon": "todo",
"background": "#fff700",
"rulerColour": "#fff700",
"iconColour": "#fff700"
},
"BUG:": {
"background": "#eb5c5c",
"icon": "bug",
"rulerColour": "#eb5c5c",
"iconColour": "#eb5c5c"
},
"TAG:": {
"background": "#38b2f4",
"icon": "tag",
"rulerColour": "#38b2f4",
"iconColour": "#38b2f4",
"rulerLane": "full"
},
"DONE:": {
"background": "#5eec95",
"icon": "check",
"rulerColour": "#5eec95",
"iconColour": "#5eec95"
},
"MARK:": {
"background": "#f90",
"icon": "note",
"rulerColour": "#f90",
"iconColour": "#f90"
},
"TEST:": {
"background": "#df7be6",
"icon": "flame",
"rulerColour": "#df7be6",
"iconColour": "#df7be6"
},
"XXX:": {
"background": "#d65d8e",
"icon": "versions",
"rulerColour": "#d65d8e",
"iconColour": "#d65d8e"
}
}
} }

View File

@ -2,13 +2,21 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import type { UseStream } from "@langchain/langgraph-sdk/react"; import type { UseStream } from "@langchain/langgraph-sdk/react";
import { FilesIcon, XIcon } from "lucide-react"; import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { usePromptInputController } from "@/components/ai-elements/prompt-input"; import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DevDialog,
DevDialogContent,
DevDialogFooter,
DevDialogHeader,
DevDialogTitle,
} from "@/components/ui/dev-dialog";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@ -20,12 +28,16 @@ import {
ArtifactFileList, ArtifactFileList,
useArtifacts, useArtifacts,
} from "@/components/workspace/artifacts"; } from "@/components/workspace/artifacts";
import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { MessageListSkeleton } from "@/components/workspace/messages/skeleton";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
@ -40,11 +52,13 @@ import {
} from "@/core/threads/utils"; } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { env } from "@/env"; import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function ChatPage() { export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
useSpecificChatMode();
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const { const {
@ -54,35 +68,17 @@ export default function ChatPage() {
setArtifacts, setArtifacts,
select: selectArtifact, select: selectArtifact,
selectedArtifact, selectedArtifact,
fullscreen,
} = useArtifacts(); } = useArtifacts();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const promptInputController = usePromptInputController(); const promptInputController = usePromptInputController();
const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined;
}
return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef(promptInputController.textInput.setInput);
setInputRef.current = promptInputController.textInput.setInput;
useEffect(() => {
if (inputInitialValue && inputInitialValue !== lastInitialValueRef.current) {
lastInitialValueRef.current = inputInitialValue;
setTimeout(() => {
setInputRef.current(inputInitialValue);
const textarea = document.querySelector("textarea");
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}
}, 100);
}
}, [inputInitialValue]);
// UI mode depends only on route: /workspace/chats/new is always "new page" mode. // UI mode depends only on route: /workspace/chats/new is always "new page" mode.
const isNewThread = useMemo(() => threadIdFromPath === "new", [threadIdFromPath]); const isNewThread = useMemo(
() => threadIdFromPath === "new",
[threadIdFromPath],
);
// Submission strategy is controlled by `isnew` query param only. // Submission strategy is controlled by `isnew` query param only.
// - isnew=false: reuse existing thread // - isnew=false: reuse existing thread
@ -100,33 +96,13 @@ export default function ChatPage() {
return target === "skill" ? "skill" : undefined; return target === "skill" ? "skill" : undefined;
}, [searchParams]); }, [searchParams]);
const skillBootstrap = useMemo(() => {
const skillIdRaw = searchParams.get("skill_id")?.trim();
if (!skillIdRaw) return undefined;
const contentId = Number(skillIdRaw);
if (!Number.isFinite(contentId)) return undefined;
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw
? Number(languageTypeRaw)
: 0;
return {
contentId,
languageType: Number.isFinite(languageType) ? languageType : 0,
};
}, [threadIdFromPath, searchParams]);
const [threadId, setThreadId] = useState<string | null>(null); const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (threadIdFromPath !== "new") { if (threadIdFromPath !== "new") {
setThreadId(threadIdFromPath); setThreadId(threadIdFromPath);
} else { } else {
const queryThreadId = searchParams.get("thread_id")?.trim(); const queryThreadId = searchParams.get("thread_id")?.trim();
setThreadId(queryThreadId || uuid()); setThreadId(queryThreadId ?? uuid());
} }
}, [threadIdFromPath, searchParams]); }, [threadIdFromPath, searchParams]);
@ -139,11 +115,14 @@ export default function ChatPage() {
); );
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [isSkillBootstrapping, setIsSkillBootstrapping] = useState(false);
const [skillBootstrapError, setSkillBootstrapError] = useState<string | null>( // 监听宿主页 selectedSkill 消息
null, const {
); selectedSkill,
const skillBootstrappedKeyRef = useRef<string | null>(null); skillError: selectedSkillError,
clearSkillError: clearSelectedSkillError,
isBootstrapping: isSelectedSkillBootstrapping,
} = useSelectedSkillListener({ threadId });
const [finalState, setFinalState] = useState<AgentThreadState | null>(null); const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
const thread = useThreadStream({ const thread = useThreadStream({
// Keep UI in new-page mode, but runtime may reuse existing thread // Keep UI in new-page mode, but runtime may reuse existing thread
@ -187,6 +166,10 @@ export default function ChatPage() {
const [hasSubmitted, setHasSubmitted] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false);
const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted; const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted;
const suppressNewThreadSubmitUi =
isNewThread && createNewSession && hasSubmitted;
const suppressConversationUi =
suppressExistingThreadPrefetchUi || suppressNewThreadSubmitUi;
useEffect(() => { useEffect(() => {
const pageTitle = isNewThread const pageTitle = isNewThread
@ -194,7 +177,7 @@ export default function ChatPage() {
: thread.values?.title && thread.values.title !== "Untitled" : thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title ? thread.values.title
: t.pages.untitled; : t.pages.untitled;
if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) { if (thread.isThreadLoading && !suppressConversationUi) {
document.title = `Loading... - ${t.pages.appName}`; document.title = `Loading... - ${t.pages.appName}`;
} else { } else {
document.title = `${pageTitle} - ${t.pages.appName}`; document.title = `${pageTitle} - ${t.pages.appName}`;
@ -206,19 +189,21 @@ export default function ChatPage() {
t.pages.appName, t.pages.appName,
thread.values.title, thread.values.title,
thread.isThreadLoading, thread.isThreadLoading,
suppressExistingThreadPrefetchUi, suppressConversationUi,
]); ]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => { useEffect(() => {
setArtifacts(thread.values.artifacts); if (!suppressConversationUi) {
if ( setArtifacts(thread.values.artifacts);
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && if (
autoSelectFirstArtifact env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
) { autoSelectFirstArtifact
if (thread?.values?.artifacts?.length > 0) { ) {
setAutoSelectFirstArtifact(false); if (thread?.values?.artifacts?.length > 0) {
selectArtifact(thread.values.artifacts[0]!); setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
} }
} }
}, [ }, [
@ -236,54 +221,7 @@ export default function ChatPage() {
}, [artifactsOpen, artifacts]); }, [artifactsOpen, artifacts]);
const [todoListCollapsed, setTodoListCollapsed] = useState(true); const [todoListCollapsed, setTodoListCollapsed] = useState(true);
const [showExitDialog, setShowExitDialog] = useState(false);
useEffect(() => {
if (!threadId || !skillBootstrap?.contentId) {
setIsSkillBootstrapping(false);
setSkillBootstrapError(null);
return;
}
const languageType = skillBootstrap.languageType ?? 0;
const initKey = `${threadId}:${skillBootstrap.contentId}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey) {
return;
}
let cancelled = false;
const runBootstrap = async () => {
setIsSkillBootstrapping(true);
setSkillBootstrapError(null);
try {
await bootstrapRemoteSkill({
thread_id: threadId,
content_id: skillBootstrap.contentId,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
if (!cancelled) {
skillBootstrappedKeyRef.current = initKey;
setIsSkillBootstrapping(false);
}
} catch (error) {
if (!cancelled) {
const message = error instanceof Error ? error.message : "Skill 初始化失败";
setSkillBootstrapError(message);
setIsSkillBootstrapping(false);
showNotification("Skill 初始化失败", { body: message });
}
}
};
void runBootstrap();
return () => {
cancelled = true;
};
}, [threadId, skillBootstrap, showNotification]);
const submitThread = useSubmitThread({ const submitThread = useSubmitThread({
isNewThread, isNewThread,
@ -304,13 +242,13 @@ export default function ChatPage() {
}); });
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: Parameters<typeof submitThread>[0]) => { (message: Parameters<typeof submitThread>[0]) => {
if (isSkillBootstrapping) { if (isSelectedSkillBootstrapping) {
return; return;
} }
setHasSubmitted(true); setHasSubmitted(true);
void submitThread(message); void submitThread(message);
}, },
[isSkillBootstrapping, submitThread], [isSelectedSkillBootstrapping, submitThread],
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
@ -324,29 +262,68 @@ export default function ChatPage() {
<ThreadContext.Provider value={{ threadId, thread }}> <ThreadContext.Provider value={{ threadId, thread }}>
<ResizablePanelGroup orientation="horizontal"> <ResizablePanelGroup orientation="horizontal">
<ResizablePanel <ResizablePanel
className="relative" className="relative overflow-hidden rounded-[20px]"
defaultSize={artifactPanelOpen ? 46 : 100} defaultSize={artifactPanelOpen ? 46 : 100}
minSize={artifactPanelOpen ? 30 : 100} minSize={artifactPanelOpen ? 30 : 100}
> >
<div className="relative flex size-full min-h-0 justify-between"> <div className="relative flex size-full min-h-0 justify-between rounded-[20px]">
<header <header
className={cn( className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4", "bg-background absolute top-0 right-0 left-0 z-30 mx-4 grid h-[58px] shrink-0 grid-cols-3 items-center border-b transition-all duration-300 ease-in-out",
isNewThread isNewThread ? "hidden" : "",
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)} )}
> >
<div className="flex w-full items-center text-sm font-medium"> <div className="flex items-center justify-start overflow-hidden text-sm font-medium">
<Button
size="sm"
variant="ghost"
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
onClick={() => setShowExitDialog(true)}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
</div>
<div className="flex items-center justify-center overflow-hidden text-sm font-medium">
{title !== "Untitled" && ( {title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={title} /> <ThreadTitle threadId={threadId} threadTitle={title} />
)} )}
</div> </div>
<div> <div className="flex items-center justify-end gap-2 overflow-hidden">
{artifacts?.length > 0 && !artifactsOpen && ( <DevTodoList
<Tooltip content="Show artifacts of this conversation"> className="bg-white"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
trigger={
<Button <Button
className="text-muted-foreground hover:text-foreground" size="sm"
variant="ghost"
className="h-full px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]"
>
<ListTodoIcon className="size-4" /> To-dos
</Button>
}
/>
{artifacts?.length > 0 && !artifactsOpen && (
<Tooltip content="点击可查看生成的文件结果">
<Button
className="text-[#150033] hover:text-[#150033]/80"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setArtifactsOpen(true); setArtifactsOpen(true);
@ -360,87 +337,28 @@ export default function ChatPage() {
)} )}
</div> </div>
</header> </header>
<main className="flex min-h-0 max-w-full grow flex-col"> <main
className={cn(
"flex min-h-0 max-w-full grow flex-col rounded-[20px]",
isNewThread ? "bg-white" : "bg-background",
)}
>
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
<MessageList {suppressConversationUi ? (
className={cn("size-full", !isNewThread && "pt-10")} <MessageListSkeleton />
threadId={threadId} ) : (
thread={thread} <MessageList
suppressThreadLoading={suppressExistingThreadPrefetchUi} className={cn("size-full", !isNewThread && "pt-10")}
messagesOverride={ threadId={threadId}
suppressExistingThreadPrefetchUi thread={thread}
? [] messagesOverride={
: !thread.isLoading && finalState?.messages !thread.isLoading && finalState?.messages
? (finalState.messages as Message[]) ? (finalState.messages as Message[])
: undefined : undefined
}
paddingBottom={todoListCollapsed ? 160 : 280}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
collapsed={todoListCollapsed}
hidden={
!thread.values.todos ||
thread.values.todos.length === 0
}
onToggle={() =>
setTodoListCollapsed(!todoListCollapsed)
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
autoFocus={isNewThread}
status={
suppressExistingThreadPrefetchUi
? "ready"
: thread.isLoading
? "streaming"
: "ready"
} }
context={settings.context} paddingBottom={todoListCollapsed ? 160 : 280}
extraHeader={
isNewThread && <Welcome mode={settings.context.mode} />
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSkillBootstrapping
}
onContextChange={(context) =>
setSettings("context", context)
}
onSubmit={handleSubmit}
onStop={handleStop}
/> />
{(isSkillBootstrapping || skillBootstrapError) && ( )}
<div className="text-muted-foreground w-full translate-y-8 text-center text-xs">
{isSkillBootstrapping
? "正在初始化 Skill 文件..."
: `Skill 初始化失败:${skillBootstrapError}`}
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div> </div>
</main> </main>
</div> </div>
@ -453,7 +371,7 @@ export default function ChatPage() {
/> />
<ResizablePanel <ResizablePanel
className={cn( className={cn(
"transition-all duration-300 ease-in-out", "bg-background ml-[20px] rounded-[20px] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0", !artifactsOpen && "opacity-0",
)} )}
defaultSize={artifactPanelOpen ? 64 : 0} defaultSize={artifactPanelOpen ? 64 : 0}
@ -462,7 +380,7 @@ export default function ChatPage() {
> >
<div <div
className={cn( className={cn(
"h-full p-4 transition-transform duration-300 ease-in-out", "h-full w-full transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full", artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)} )}
> >
@ -473,7 +391,7 @@ export default function ChatPage() {
threadId={threadId} threadId={threadId}
/> />
) : ( ) : (
<div className="relative flex size-full justify-center"> <div className="relative flex size-full justify-center px-[20px]">
<div className="absolute top-1 right-1 z-30"> <div className="absolute top-1 right-1 z-30">
<Button <Button
size="icon-sm" size="icon-sm"
@ -494,7 +412,9 @@ export default function ChatPage() {
) : ( ) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8"> <div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0"> <header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2> <h2 className="text-lg font-medium">
{t.common.artifacts}
</h2>
</header> </header>
<main className="min-h-0 grow"> <main className="min-h-0 grow">
<ArtifactFileList <ArtifactFileList
@ -510,6 +430,120 @@ export default function ChatPage() {
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
{/* Fixed 底部居中输入框容器 */}
<div
className={cn(
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4",
"transition-all duration-300 ease-in-out",
fullscreen ? "right-[50%]" : "",
)}
>
<div
className={cn(
"pointer-events-auto relative w-full max-w-[720px]",
isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]",
)}
>
<InputBox
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
isNewThread={isNewThread}
autoFocus={isNewThread}
status={
suppressExistingThreadPrefetchUi
? "ready"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
<div className="flex flex-col gap-4">
{isNewThread && <Welcome mode={settings.context.mode} />}
</div>
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSelectedSkillBootstrapping
}
onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{isSelectedSkillBootstrapping && (
<div className="text-muted-foreground w-full translate-y-8 text-center text-xs">
Skill ...
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
{/* 退出确认对话框 */}
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}>
<DevDialogContent>
<DevDialogHeader>
<DevDialogTitle></DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
退
</p>
<DevDialogFooter>
<Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
variant="ghost"
onClick={() => setShowExitDialog(false)}
>
</Button>
<Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
variant="ghost"
onClick={() => {
setShowExitDialog(false);
router.push("/workspace/chats/new");
}}
>
</Button>
</DevDialogFooter>
</DevDialogContent>
</DevDialog>
{/* selectedSkill 失败:错误弹窗 */}
<DevDialog
open={!!selectedSkillError}
onOpenChange={(open) => {
if (!open) clearSelectedSkillError();
}}
>
<DevDialogContent>
<DevDialogHeader>
<DevDialogTitle>
{selectedSkillError?.title ?? "技能加载失败"}
</DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
{selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"}
</p>
<DevDialogFooter singleColumn>
<Button
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
variant="ghost"
onClick={clearSelectedSkillError}
>
</Button>
</DevDialogFooter>
</DevDialogContent>
</DevDialog>
{/* MARK: 开发测试iframe 通信功能测试面板 */}
{/* <IframeTestPanel /> */}
</ThreadContext.Provider> </ThreadContext.Provider>
); );
} }

View File

@ -1,12 +1,13 @@
"use client"; "use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation";
import { Toaster } from "sonner"; import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { useLocalSettings } from "@/core/settings"; import { getLocalSettings, useLocalSettings } from "@/core/settings";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -14,7 +15,16 @@ export default function WorkspaceLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const [open, setOpen] = useState(() => !settings.layout.sidebar_collapsed); const [open, setOpen] = useState(false); // SSR default: open (matches server render)
const searchParams = useSearchParams();
// iframe 技能模式mode=skill时隐藏侧边栏
const isSkillMode = searchParams.get("mode") === "skill";
useLayoutEffect(() => {
// Runs synchronously before first paint on the client — no visual flash
setOpen(!getLocalSettings().layout.sidebar_collapsed);
}, []);
useEffect(() => { useEffect(() => {
setOpen(!settings.layout.sidebar_collapsed); setOpen(!settings.layout.sidebar_collapsed);
}, [settings.layout.sidebar_collapsed]); }, [settings.layout.sidebar_collapsed]);
@ -32,10 +42,38 @@ export default function WorkspaceLayout({
open={open} open={open}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
> >
<WorkspaceSidebar /> {/* MARK: 生产环境下必须注释才能提交!!!! */}
{/* {!isSkillMode && <WorkspaceSidebar className="" />} */}
<SidebarInset className="min-w-0">{children}</SidebarInset> <SidebarInset className="min-w-0">{children}</SidebarInset>
</SidebarProvider> </SidebarProvider>
<Toaster position="top-center" /> <Toaster
position="top-center"
toastOptions={{
duration: 2200,
classNames: {
toast: [
/* 灰色圆角矩形容器 */
"rounded-[20px] border-none",
/* 浅灰色背景 + 轻微透明 */
"bg-[#999999]! backdrop-blur-sm",
/* 阴影极轻 */
"shadow-[0_2px_12px_0_rgba(0,0,0,0.18)]",
/* 内边距:宽松居中 */
"px-5 py-2.5",
/* 单行布局,内容水平居中 */
"flex items-center justify-center gap-0",
/* 整体文字样式 */
"text-white text-sm font-normal font-sans",
/* 去掉 icon 区域间距 */
"[&>[data-icon]]:hidden",
].join(" "),
title:
"text-white! text-sm font-normal text-center w-full leading-snug",
description: "hidden",
icon: "hidden",
},
}}
/>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => ( export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div <div
className={cn( className={cn(
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-lg", "bg-background flex flex-col overflow-hidden rounded-[20px] px-[20px] pt-[15px]",
className, className,
)} )}
{...props} {...props}
@ -31,7 +31,7 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => ( }: ArtifactHeaderProps) => (
<div <div
className={cn( className={cn(
"bg-muted/50 flex items-center justify-between border-b px-4 py-3", "mb-[20px] grid grid-cols-3 items-center justify-between",
className, className,
)} )}
{...props} {...props}
@ -143,8 +143,7 @@ export const ArtifactContent = ({
className, className,
...props ...props
}: ArtifactContentProps) => ( }: ArtifactContentProps) => (
<div <div className="min-h-0 flex-1 overflow-auto">
className={cn("min-h-0 flex-1 overflow-auto p-4", className)} <div className={cn("mb-[208px] p-4", className)} {...props} />
{...props} </div>
/>
); );

View File

@ -147,7 +147,11 @@ export const ChainOfThoughtStep = memo(
{...props} {...props}
> >
<div className="relative mt-0.5"> <div className="relative mt-0.5">
{isValidElement(Icon) ? Icon : <Icon className="size-4" />} {isValidElement(Icon) ? (
Icon
) : (
<Icon className="size-4 stroke-[1.5px] stroke-[#333333] text-[#333333]" />
)}
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" /> <div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</div> </div>
<div className="flex-1 space-y-2 overflow-hidden"> <div className="flex-1 space-y-2 overflow-hidden">
@ -202,7 +206,7 @@ export const ChainOfThoughtContent = memo(
<Collapsible open={isOpen}> <Collapsible open={isOpen}>
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"mt-2 space-y-3", "mt-2 space-y-3 bg-[#ffffff]",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className, className,
)} )}

View File

@ -19,7 +19,10 @@ export const Checkpoint = ({
...props ...props
}: CheckpointProps) => ( }: CheckpointProps) => (
<div <div
className={cn("flex items-center gap-0.5 text-muted-foreground overflow-hidden", className)} className={cn(
"text-muted-foreground flex items-center gap-0.5 overflow-hidden",
className,
)}
{...props} {...props}
> >
{children} {children}

View File

@ -115,7 +115,7 @@ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
{children ?? ( {children ?? (
<Button type="button" variant="ghost" {...props}> <Button type="button" variant="ghost" {...props}>
<span className="font-medium text-muted-foreground"> <span className="text-muted-foreground font-medium">
{renderedPercent} {renderedPercent}
</span> </span>
<ContextIcon /> <ContextIcon />
@ -163,7 +163,7 @@ export const ContextContentHeader = ({
<> <>
<div className="flex items-center justify-between gap-3 text-xs"> <div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p> <p>{displayPct}</p>
<p className="font-mono text-muted-foreground"> <p className="text-muted-foreground font-mono">
{used} / {total} {used} / {total}
</p> </p>
</div> </div>
@ -213,8 +213,8 @@ export const ContextContentFooter = ({
return ( return (
<div <div
className={cn( className={cn(
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs", "bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs",
className className,
)} )}
{...props} {...props}
> >
@ -402,7 +402,7 @@ const TokensWithCost = ({
notation: "compact", notation: "compact",
}).format(tokens)} }).format(tokens)}
{costText ? ( {costText ? (
<span className="ml-2 text-muted-foreground"> {costText}</span> <span className="text-muted-foreground ml-2"> {costText}</span>
) : null} ) : null}
</span> </span>
); );

View File

@ -9,9 +9,9 @@ export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
export const Controls = ({ className, ...props }: ControlsProps) => ( export const Controls = ({ className, ...props }: ControlsProps) => (
<ControlsPrimitive <ControlsPrimitive
className={cn( className={cn(
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!", "bg-card gap-px overflow-hidden rounded-md border p-1 shadow-none!",
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!", "[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!",
className className,
)} )}
{...props} {...props}
/> />

View File

@ -28,7 +28,7 @@ export const ConversationContent = ({
...props ...props
}: ConversationContentProps) => ( }: ConversationContentProps) => (
<StickToBottom.Content <StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)} className={cn("flex flex-col gap-8 p-[20px]", className)}
{...props} {...props}
/> />
); );

View File

@ -29,7 +29,7 @@ const Temporary = ({
return ( return (
<BaseEdge <BaseEdge
className="stroke-1 stroke-ring" className="stroke-ring stroke-1"
id={id} id={id}
path={edgePath} path={edgePath}
style={{ style={{
@ -41,13 +41,13 @@ const Temporary = ({
const getHandleCoordsByPosition = ( const getHandleCoordsByPosition = (
node: InternalNode<Node>, node: InternalNode<Node>,
handlePosition: Position handlePosition: Position,
) => { ) => {
// Choose the handle type based on position - Left is for target, Right is for source // Choose the handle type based on position - Left is for target, Right is for source
const handleType = handlePosition === Position.Left ? "target" : "source"; const handleType = handlePosition === Position.Left ? "target" : "source";
const handle = node.internals.handleBounds?.[handleType]?.find( const handle = node.internals.handleBounds?.[handleType]?.find(
(h) => h.position === handlePosition (h) => h.position === handlePosition,
); );
if (!handle) { if (!handle) {
@ -85,7 +85,7 @@ const getHandleCoordsByPosition = (
const getEdgeParams = ( const getEdgeParams = (
source: InternalNode<Node>, source: InternalNode<Node>,
target: InternalNode<Node> target: InternalNode<Node>,
) => { ) => {
const sourcePos = Position.Right; const sourcePos = Position.Right;
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
@ -112,7 +112,7 @@ const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode, sourceNode,
targetNode targetNode,
); );
const [edgePath] = getBezierPath({ const [edgePath] = getBezierPath({

View File

@ -17,7 +17,7 @@ export const Image = ({
alt={props.alt} alt={props.alt}
className={cn( className={cn(
"h-auto max-w-full overflow-hidden rounded-md", "h-auto max-w-full overflow-hidden rounded-md",
props.className props.className,
)} )}
src={`data:${mediaType};base64,${base64}`} src={`data:${mediaType};base64,${base64}`}
/> />

View File

@ -87,7 +87,7 @@ export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div <div
className={cn( className={cn(
"inline-flex animate-spin items-center justify-center", "inline-flex animate-spin items-center justify-center",
className className,
)} )}
{...props} {...props}
> >

View File

@ -27,8 +27,10 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
export const Message = ({ className, from, ...props }: MessageProps) => ( export const Message = ({ className, from, ...props }: MessageProps) => (
<div <div
className={cn( className={cn(
"group flex w-full flex-col gap-2", "group flex w-full flex-col gap-2 rounded-[10px] p-[20px]",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant", from === "user"
? "is-user ml-auto justify-end"
: "is-assistant bg-[#ffffff]",
className, className,
)} )}
{...props} {...props}
@ -44,7 +46,8 @@ export const MessageContent = ({
}: MessageContentProps) => ( }: MessageContentProps) => (
<div <div
className={cn( className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden", "is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-visible",
"group-[.is-user]:overflow-hidden",
"group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3", "group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3",
"group-[.is-assistant]:text-foreground", "group-[.is-assistant]:text-foreground",
className, className,

View File

@ -22,7 +22,7 @@ export const Node = ({ handles, className, ...props }: NodeProps) => (
<Card <Card
className={cn( className={cn(
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0", "node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
className className,
)} )}
{...props} {...props}
> >
@ -36,7 +36,7 @@ export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
<CardHeader <CardHeader
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)} className={cn("bg-secondary gap-0.5 rounded-t-md border-b p-3!", className)}
{...props} {...props}
/> />
); );
@ -65,7 +65,7 @@ export type NodeFooterProps = ComponentProps<typeof CardFooter>;
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
<CardFooter <CardFooter
className={cn("rounded-b-md border-t bg-secondary p-3!", className)} className={cn("bg-secondary rounded-b-md border-t p-3!", className)}
{...props} {...props}
/> />
); );

View File

@ -7,8 +7,8 @@ type PanelProps = ComponentProps<typeof PanelPrimitive>;
export const Panel = ({ className, ...props }: PanelProps) => ( export const Panel = ({ className, ...props }: PanelProps) => (
<PanelPrimitive <PanelPrimitive
className={cn( className={cn(
"m-4 overflow-hidden rounded-md border bg-card p-1", "bg-card m-4 overflow-hidden rounded-md border p-1",
className className,
)} )}
{...props} {...props}
/> />

View File

@ -35,6 +35,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Tooltip } from "../workspace/tooltip";
import type { ChatStatus, FileUIPart } from "ai"; import type { ChatStatus, FileUIPart } from "ai";
import { import {
ArrowUpIcon, ArrowUpIcon,
@ -70,6 +71,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useI18n } from "@/core/i18n/hooks";
// ============================================================================ // ============================================================================
// Provider Context & Types // Provider Context & Types
@ -295,81 +297,112 @@ export function PromptInputAttachment({
data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image"; const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); const truncateFilename = (name: string, maxLen: number = 10) => {
if (name.length <= maxLen) return name;
const ext = name.slice(name.lastIndexOf("."));
const baseName = name.slice(0, name.lastIndexOf("."));
const truncated = baseName.slice(0, maxLen - ext.length - 3);
return truncated + "..." + ext;
};
return ( return (
<PromptInputHoverCard> <div
<HoverCardTrigger asChild> className={cn(
<div "group relative flex size-16 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-lg transition-all select-none",
className={cn( isImage ? "p-0" : "bg-gray-100 dark:bg-gray-700",
"group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none", className,
className, )}
)} key={data.id}
key={data.id} {...props}
{...props} >
> {isImage ? (
<div className="relative size-5 shrink-0"> <>
<div className="bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0"> <img
{isImage ? ( alt={filename || "attachment"}
<img className="size-full object-cover"
alt={filename || "attachment"} src={data.url}
className="size-5 object-cover" />
height={20} {/* 悬浮遮罩层 */}
src={data.url} <div
width={20} className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
/> style={{ borderRadius: "10px", background: "rgba(0, 0, 0, 0.60)" }}
) : ( >
<div className="text-muted-foreground flex size-5 items-center justify-center"> {/* 眼睛图标 - 居中 */}
<PaperclipIcon className="size-3" /> <svg
</div> xmlns="http://www.w3.org/2000/svg"
)} width="20"
</div> height="20"
<Button viewBox="0 0 20 20"
fill="none"
>
<path
d="M10 4.75C13.3315 4.75 16.4669 6.61444 18.9805 9.88281C19.0335 9.95183 19.0335 10.0482 18.9805 10.1172C16.4669 13.3856 13.3315 15.25 10 15.25C6.66835 15.2499 3.53309 13.3857 1.01953 10.1172C0.966466 10.0482 0.966465 9.95182 1.01953 9.88281C3.53309 6.61435 6.66835 4.75014 10 4.75Z"
stroke="white"
strokeWidth="1.5"
/>
<path
d="M10 7.75C11.2426 7.75 12.25 8.75736 12.25 10C12.25 11.2426 11.2426 12.25 10 12.25C8.75736 12.25 7.75 11.2426 7.75 10C7.75 8.75736 8.75736 7.75 10 7.75Z"
stroke="white"
strokeWidth="1.5"
/>
</svg>
{/* 删除按钮 - 右上角 */}
<button
aria-label="Remove attachment" aria-label="Remove attachment"
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5" className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-white/20"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
attachments.remove(data.id); attachments.remove(data.id);
}} }}
type="button" type="button"
variant="ghost"
> >
<XIcon /> <svg
<span className="sr-only">Remove</span> xmlns="http://www.w3.org/2000/svg"
</Button> width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<path
d="M0.75 0.75L6.74995 6.74995"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M6.75 0.75L0.750025 6.74992"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div> </div>
</>
<span className="flex-1 truncate">{attachmentLabel}</span> ) : (
</div> <>
</HoverCardTrigger> <div className="flex flex-col items-center justify-center gap-1 px-1">
<PromptInputHoverCardContent className="w-auto p-2"> <PaperclipIcon className="size-6 text-gray-400" />
<div className="w-auto space-y-3"> <span className="max-w-full truncate text-center text-[10px] text-gray-500">
{isImage && ( {truncateFilename(filename)}
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border"> </span>
<img
alt={filename || "attachment preview"}
className="max-h-full max-w-full object-contain"
height={384}
src={data.url}
width={448}
/>
</div>
)}
<div className="flex items-center gap-2.5">
<div className="min-w-0 flex-1 space-y-1 px-0.5">
<h4 className="truncate text-sm leading-none font-semibold">
{filename || (isImage ? "Image" : "Attachment")}
</h4>
{data.mediaType && (
<p className="text-muted-foreground truncate font-mono text-xs">
{data.mediaType}
</p>
)}
</div>
</div> </div>
</div> {/* 关闭按钮 - 右上角 */}
</PromptInputHoverCardContent> <button
</PromptInputHoverCard> aria-label="Remove attachment"
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-white/90 transition-colors hover:bg-white dark:bg-gray-800/90 dark:hover:bg-gray-800"
onClick={(e) => {
e.stopPropagation();
attachments.remove(data.id);
}}
type="button"
>
<XIcon className="size-3 text-gray-600 dark:text-gray-300" />
<span className="sr-only">Remove</span>
</button>
</>
)}
</div>
); );
} }
@ -393,13 +426,14 @@ export function PromptInputAttachments({
return ( return (
<div <div
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)} className={cn(
"inline-flex flex-row flex-nowrap items-center gap-2 rounded-xl p-2",
className,
)}
{...props} {...props}
> >
{attachments.files.map((file) => ( {attachments.files.map((file) => (
<Fragment key={file.id}> <Fragment key={file.id}>{children(file)}</Fragment>
<div className="max-w-60">{children(file)}</div>
</Fragment>
))} ))}
</div> </div>
); );
@ -457,10 +491,13 @@ export type PromptInputProps = Omit<
message: PromptInputMessage, message: PromptInputMessage,
event: FormEvent<HTMLFormElement>, event: FormEvent<HTMLFormElement>,
) => void | Promise<void>; ) => void | Promise<void>;
// className for InputGroup (passes through to inner InputGroup component)
inputGroupClassName?: string;
}; };
export const PromptInput = ({ export const PromptInput = ({
className, className,
inputGroupClassName,
accept, accept,
disabled, disabled,
multiple, multiple,
@ -794,7 +831,7 @@ export const PromptInput = ({
ref={formRef} ref={formRef}
{...props} {...props}
> >
<InputGroup>{children}</InputGroup> <InputGroup className={inputGroupClassName}>{children}</InputGroup>
</form> </form>
</> </>
); );
@ -1027,32 +1064,63 @@ export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
export const PromptInputSubmit = ({ export const PromptInputSubmit = ({
className, className,
variant = "default", variant = "default",
size = "icon-sm", size = "sm",
status, status,
disabled,
children, children,
...props ...props
}: PromptInputSubmitProps) => { }: PromptInputSubmitProps) => {
const controller = useOptionalPromptInputController();
const { t } = useI18n();
// 判断是否有内容可发送
const hasContent = controller
? controller.textInput.value.trim().length > 0 ||
controller.attachments.files.length > 0
: false;
// 正在 streaming 时不允许发送
// const isStreaming = status === "streaming" || status === "submitted";
// const isDisabled = disabled || !hasContent || isStreaming;
let Icon = <ArrowUpIcon className="size-4" />; let Icon = <ArrowUpIcon className="size-4" />;
let text: string = "发送";
if (status === "submitted") { if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />; Icon = <Loader2Icon className="size-4 animate-spin" />;
text = "生成中...";
} else if (status === "streaming") { } else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />; Icon = <SquareIcon className="size-4" />;
text = "停止";
} else if (status === "error") { } else if (status === "error") {
Icon = <XIcon className="size-4" />; Icon = <XIcon className="size-4" />;
text = "错误";
} }
return ( return (
<InputGroupButton <Tooltip content={t.inputBox.sendMessagePrice}>
aria-label="Submit" <InputGroupButton
className={cn(className)} aria-label="Submit"
size={size} // 被button{bgc:#fff}覆盖了,只能加"!"
type="submit" className={cn(
variant={variant} "h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
{...props} // isDisabled
> // ? "cursor-not-allowed !bg-gray-200 text-gray-400":
{children ?? Icon} "!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
</InputGroupButton> className,
)}
size={size}
type="submit"
variant={variant}
// disabled={isDisabled}
{...props}
>
{/* {children ?? Icon} */}
{text}
</InputGroupButton>
</Tooltip>
); );
}; };

View File

@ -36,8 +36,8 @@ export type QueueItemProps = ComponentProps<"li">;
export const QueueItem = ({ className, ...props }: QueueItemProps) => ( export const QueueItem = ({ className, ...props }: QueueItemProps) => (
<li <li
className={cn( className={cn(
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted", "group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors",
className className,
)} )}
{...props} {...props}
/> />
@ -58,7 +58,7 @@ export const QueueItemIndicator = ({
completed completed
? "border-muted-foreground/20 bg-muted-foreground/10" ? "border-muted-foreground/20 bg-muted-foreground/10"
: "border-muted-foreground/50", : "border-muted-foreground/50",
className className,
)} )}
{...props} {...props}
/> />
@ -79,7 +79,7 @@ export const QueueItemContent = ({
completed completed
? "text-muted-foreground/50 line-through" ? "text-muted-foreground/50 line-through"
: "text-muted-foreground", : "text-muted-foreground",
className className,
)} )}
{...props} {...props}
/> />
@ -100,7 +100,7 @@ export const QueueItemDescription = ({
completed completed
? "text-muted-foreground/40 line-through" ? "text-muted-foreground/40 line-through"
: "text-muted-foreground", : "text-muted-foreground",
className className,
)} )}
{...props} {...props}
/> />
@ -126,8 +126,8 @@ export const QueueItemAction = ({
}: QueueItemActionProps) => ( }: QueueItemActionProps) => (
<Button <Button
className={cn( className={cn(
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100", "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground size-auto rounded p-1 opacity-0 transition-opacity group-hover:opacity-100",
className className,
)} )}
size="icon" size="icon"
type="button" type="button"
@ -169,8 +169,8 @@ export const QueueItemFile = ({
}: QueueItemFileProps) => ( }: QueueItemFileProps) => (
<span <span
className={cn( className={cn(
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs", "bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs",
className className,
)} )}
{...props} {...props}
> >
@ -186,8 +186,8 @@ export const QueueList = ({
className, className,
...props ...props
}: QueueListProps) => ( }: QueueListProps) => (
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}> <ScrollArea className={cn("-mb-1", className)} {...props}>
<div className="max-h-40 pr-4"> <div className="max-h-40">
<ul>{children}</ul> <ul>{children}</ul>
</div> </div>
</ScrollArea> </ScrollArea>
@ -215,8 +215,8 @@ export const QueueSectionTrigger = ({
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
className={cn( className={cn(
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted", "group bg-muted/40 text-muted-foreground hover:bg-muted flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium transition-colors",
className className,
)} )}
type="button" type="button"
{...props} {...props}
@ -241,7 +241,7 @@ export const QueueSectionLabel = ({
...props ...props
}: QueueSectionLabelProps) => ( }: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}> <span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" /> <ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
{icon} {icon}
<span> <span>
{count} {label} {count} {label}
@ -266,8 +266,8 @@ export type QueueProps = ComponentProps<"div">;
export const Queue = ({ className, ...props }: QueueProps) => ( export const Queue = ({ className, ...props }: QueueProps) => (
<div <div
className={cn( className={cn(
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs", "border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs",
className className,
)} )}
{...props} {...props}
/> />

View File

@ -108,10 +108,12 @@ export const Reasoning = memo(
</Collapsible> </Collapsible>
</ReasoningContext.Provider> </ReasoningContext.Provider>
); );
} },
); );
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> & { export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode; getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
}; };
@ -126,14 +128,19 @@ const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
}; };
export const ReasoningTrigger = memo( export const ReasoningTrigger = memo(
({ className, children, getThinkingMessage = defaultGetThinkingMessage, ...props }: ReasoningTriggerProps) => { ({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning(); const { isStreaming, isOpen, duration } = useReasoning();
return ( return (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground", "text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
className className,
)} )}
{...props} {...props}
> >
@ -144,14 +151,14 @@ export const ReasoningTrigger = memo(
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
"size-4 transition-transform", "size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0" isOpen ? "rotate-180" : "rotate-0",
)} )}
/> />
</> </>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
); );
} },
); );
export type ReasoningContentProps = ComponentProps< export type ReasoningContentProps = ComponentProps<
@ -165,14 +172,14 @@ export const ReasoningContent = memo(
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"mt-4 text-sm", "mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className className,
)} )}
{...props} {...props}
> >
<Streamdown {...props}>{children}</Streamdown> <Streamdown {...props}>{children}</Streamdown>
</CollapsibleContent> </CollapsibleContent>
) ),
); );
Reasoning.displayName = "Reasoning"; Reasoning.displayName = "Reasoning";

View File

@ -26,12 +26,12 @@ const ShimmerComponent = ({
spread = 2, spread = 2,
}: TextShimmerProps) => { }: TextShimmerProps) => {
const MotionComponent = motion.create( const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements Component as keyof JSX.IntrinsicElements,
); );
const dynamicSpread = useMemo( const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread, () => (children?.length ?? 0) * spread,
[children, spread] [children, spread],
); );
return ( return (
@ -39,8 +39,8 @@ const ShimmerComponent = ({
animate={{ backgroundPosition: "0% center" }} animate={{ backgroundPosition: "0% center" }}
className={cn( className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent", "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]", "[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]",
className className,
)} )}
initial={{ backgroundPosition: "100% center" }} initial={{ backgroundPosition: "100% center" }}
style={ style={

View File

@ -13,7 +13,7 @@ export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => ( export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible <Collapsible
className={cn("not-prose mb-4 text-primary text-xs", className)} className={cn("not-prose text-primary mb-4 text-xs", className)}
{...props} {...props}
/> />
); );
@ -50,8 +50,8 @@ export const SourcesContent = ({
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"mt-3 flex w-fit flex-col gap-2", "mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className className,
)} )}
{...props} {...props}
/> />

View File

@ -61,16 +61,17 @@ export const Suggestion = ({
return ( return (
<Button <Button
className={cn( className={cn(
"text-muted-foreground cursor-pointer rounded-full px-4 text-xs font-normal", "cursor-pointer rounded-full px-[20px] py-[15px] text-xs font-normal",
"bg-[#F9F8FA] text-[#666666] border-none",
"hover:bg-[#EAE9EB] hover:text-[#150033]",
className, className,
)} )}
onClick={handleClick} onClick={handleClick}
size={size} size={size}
type="button" type="button"
variant={variant}
{...props} {...props}
> >
{Icon && <Icon className="size-4" />} {/* {Icon && <Icon className="size-4" />} */}
{children || suggestion} {children || suggestion}
</Button> </Button>
); );

View File

@ -18,8 +18,8 @@ export const TaskItemFile = ({
}: TaskItemFileProps) => ( }: TaskItemFileProps) => (
<div <div
className={cn( className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs", "bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs",
className className,
)} )}
{...props} {...props}
> >
@ -57,7 +57,7 @@ export const TaskTrigger = ({
}: TaskTriggerProps) => ( }: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}> <CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? ( {children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"> <div className="text-muted-foreground hover:text-foreground flex w-full cursor-pointer items-center gap-2 text-sm transition-colors">
<SearchIcon className="size-4" /> <SearchIcon className="size-4" />
<p className="text-sm">{title}</p> <p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" /> <ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
@ -75,12 +75,12 @@ export const TaskContent = ({
}: TaskContentProps) => ( }: TaskContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className className,
)} )}
{...props} {...props}
> >
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4"> <div className="border-muted mt-4 space-y-2 border-l-2 pl-4">
{children} {children}
</div> </div>
</CollapsibleContent> </CollapsibleContent>

View File

@ -7,8 +7,8 @@ type ToolbarProps = ComponentProps<typeof NodeToolbar>;
export const Toolbar = ({ className, ...props }: ToolbarProps) => ( export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<NodeToolbar <NodeToolbar
className={cn( className={cn(
"flex items-center gap-1 rounded-sm border bg-background p-1.5", "bg-background flex items-center gap-1 rounded-sm border p-1.5",
className className,
)} )}
position={Position.Bottom} position={Position.Bottom}
{...props} {...props}

View File

@ -66,8 +66,8 @@ export const WebPreview = ({
<WebPreviewContext.Provider value={contextValue}> <WebPreviewContext.Provider value={contextValue}>
<div <div
className={cn( className={cn(
"flex size-full flex-col rounded-lg border bg-card", "bg-card flex size-full flex-col rounded-lg border",
className className,
)} )}
{...props} {...props}
> >
@ -107,7 +107,7 @@ export const WebPreviewNavigationButton = ({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className="h-8 w-8 p-0 hover:text-foreground" className="hover:text-foreground h-8 w-8 p-0"
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
size="sm" size="sm"
@ -209,21 +209,21 @@ export const WebPreviewConsole = ({
return ( return (
<Collapsible <Collapsible
className={cn("border-t bg-muted/50 font-mono text-sm", className)} className={cn("bg-muted/50 border-t font-mono text-sm", className)}
onOpenChange={setConsoleOpen} onOpenChange={setConsoleOpen}
open={consoleOpen} open={consoleOpen}
{...props} {...props}
> >
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50" className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium"
variant="ghost" variant="ghost"
> >
Console Console
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200", "h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180" consoleOpen && "rotate-180",
)} )}
/> />
</Button> </Button>
@ -231,7 +231,7 @@ export const WebPreviewConsole = ({
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"px-4 pb-4", "px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in" "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
)} )}
> >
<div className="max-h-48 space-y-1 overflow-y-auto"> <div className="max-h-48 space-y-1 overflow-y-auto">
@ -244,7 +244,7 @@ export const WebPreviewConsole = ({
"text-xs", "text-xs",
log.level === "error" && "text-destructive", log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600", log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground" log.level === "log" && "text-foreground",
)} )}
key={`${log.timestamp.getTime()}-${index}`} key={`${log.timestamp.getTime()}-${index}`}
> >

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Alert({ function Alert({
className, className,
@ -31,7 +31,7 @@ function Alert({
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="alert-title" data-slot="alert-title"
className={cn( className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDescription({ function AlertDescription({
@ -56,11 +56,11 @@ function AlertDescription({
data-slot="alert-description" data-slot="alert-description"
className={cn( className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };

View File

@ -1,12 +1,12 @@
"use client" "use client";
import React, { memo } from "react" import React, { memo } from "react";
interface AuroraTextProps { interface AuroraTextProps {
children: React.ReactNode children: React.ReactNode;
className?: string className?: string;
colors?: string[] colors?: string[];
speed?: number speed?: number;
} }
export const AuroraText = memo( export const AuroraText = memo(
@ -23,7 +23,7 @@ export const AuroraText = memo(
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent", WebkitTextFillColor: "transparent",
animationDuration: `${10 / speed}s`, animationDuration: `${10 / speed}s`,
} };
return ( return (
<span className={`relative inline-block ${className}`}> <span className={`relative inline-block ${className}`}>
@ -36,8 +36,8 @@ export const AuroraText = memo(
{children} {children}
</span> </span>
</span> </span>
) );
} },
) );
AuroraText.displayName = "AuroraText" AuroraText.displayName = "AuroraText";

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Avatar({ function Avatar({
className, className,
@ -14,11 +14,11 @@ function Avatar({
data-slot="avatar" data-slot="avatar"
className={cn( className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full", "relative flex size-8 shrink-0 overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarImage({ function AvatarImage({
@ -31,7 +31,7 @@ function AvatarImage({
className={cn("aspect-square size-full", className)} className={cn("aspect-square size-full", className)}
{...props} {...props}
/> />
) );
} }
function AvatarFallback({ function AvatarFallback({
@ -43,11 +43,11 @@ function AvatarFallback({
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
"bg-muted flex size-full items-center justify-center rounded-full", "bg-muted flex size-full items-center justify-center rounded-full",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };

View File

@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@ -32,7 +32,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@ -1,11 +1,11 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react" import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@ -14,11 +14,11 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
@ -28,7 +28,7 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("inline-flex items-center gap-1.5", className)} className={cn("inline-flex items-center gap-1.5", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbLink({ function BreadcrumbLink({
@ -36,9 +36,9 @@ function BreadcrumbLink({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@ -46,7 +46,7 @@ function BreadcrumbLink({
className={cn("hover:text-foreground transition-colors", className)} className={cn("hover:text-foreground transition-colors", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
@ -59,7 +59,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
className={cn("text-foreground font-normal", className)} className={cn("text-foreground font-normal", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbSeparator({ function BreadcrumbSeparator({
@ -77,7 +77,7 @@ function BreadcrumbSeparator({
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) );
} }
function BreadcrumbEllipsis({ function BreadcrumbEllipsis({
@ -95,7 +95,7 @@ function BreadcrumbEllipsis({
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) );
} }
export { export {
@ -106,4 +106,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };

View File

@ -1,8 +1,8 @@
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva( const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
@ -18,8 +18,8 @@ const buttonGroupVariants = cva(
defaultVariants: { defaultVariants: {
orientation: "horizontal", orientation: "horizontal",
}, },
} },
) );
function ButtonGroup({ function ButtonGroup({
className, className,
@ -34,7 +34,7 @@ function ButtonGroup({
className={cn(buttonGroupVariants({ orientation }), className)} className={cn(buttonGroupVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function ButtonGroupText({ function ButtonGroupText({
@ -42,19 +42,19 @@ function ButtonGroupText({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
className={cn( className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", "bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ButtonGroupSeparator({ function ButtonGroupSeparator({
@ -68,11 +68,11 @@ function ButtonGroupSeparator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", "bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -80,4 +80,4 @@ export {
ButtonGroupSeparator, ButtonGroupSeparator,
ButtonGroupText, ButtonGroupText,
buttonGroupVariants, buttonGroupVariants,
} };

View File

@ -18,7 +18,7 @@ const buttonVariants = cva(
secondary: secondary:
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80", "cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"cursor-pointer hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "cursor-pointer bg-transparent hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "cursor-pointer text-primary underline-offset-4 hover:underline", link: "cursor-pointer text-primary underline-offset-4 hover:underline",
}, },
size: { size: {

View File

@ -1,18 +1,18 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-[20px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -20,12 +20,12 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@ -1,45 +1,45 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react" } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react" import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0] type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1] type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = { type CarouselProps = {
opts?: CarouselOptions opts?: CarouselOptions;
plugins?: CarouselPlugin plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical" orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void;
} };
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error("useCarousel must be used within a <Carousel />") throw new Error("useCarousel must be used within a <Carousel />");
} }
return context return context;
} }
function Carousel({ function Carousel({
@ -56,53 +56,53 @@ function Carousel({
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === "horizontal" ? "x" : "y",
}, },
plugins plugins,
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return if (!api) return;
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === "ArrowLeft") {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === "ArrowRight") { } else if (event.key === "ArrowRight") {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext] [scrollPrev, scrollNext],
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) return if (!api || !setApi) return;
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) return if (!api) return;
onSelect(api) onSelect(api);
api.on("reInit", onSelect) api.on("reInit", onSelect);
api.on("select", onSelect) api.on("select", onSelect);
return () => { return () => {
api?.off("select", onSelect) api?.off("select", onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@ -129,11 +129,11 @@ function Carousel({
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} }
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div <div
@ -145,16 +145,16 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"flex", "flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@ -164,11 +164,11 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"min-w-0 shrink-0 grow-0 basis-full", "min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4", orientation === "horizontal" ? "pl-4" : "pt-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CarouselPrevious({ function CarouselPrevious({
@ -177,7 +177,7 @@ function CarouselPrevious({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@ -189,7 +189,7 @@ function CarouselPrevious({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2" ? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
onClick={scrollPrev} onClick={scrollPrev}
@ -198,7 +198,7 @@ function CarouselPrevious({
<ArrowLeft /> <ArrowLeft />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
} }
function CarouselNext({ function CarouselNext({
@ -207,7 +207,7 @@ function CarouselNext({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@ -219,7 +219,7 @@ function CarouselNext({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2" ? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
onClick={scrollNext} onClick={scrollNext}
@ -228,7 +228,7 @@ function CarouselNext({
<ArrowRight /> <ArrowRight />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
} }
export { export {
@ -238,4 +238,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@ -1,17 +1,17 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react" import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
function Command({ function Command({
className, className,
@ -22,11 +22,11 @@ function Command({
data-slot="command" data-slot="command"
className={cn( className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CommandDialog({ function CommandDialog({
@ -37,10 +37,10 @@ function CommandDialog({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof Dialog> & { }: React.ComponentProps<typeof Dialog> & {
title?: string title?: string;
description?: string description?: string;
className?: string className?: string;
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
@ -57,7 +57,7 @@ function CommandDialog({
</Command> </Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
function CommandInput({ function CommandInput({
@ -74,12 +74,12 @@ function CommandInput({
data-slot="command-input" data-slot="command-input"
className={cn( className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function CommandList({ function CommandList({
@ -91,11 +91,11 @@ function CommandList({
data-slot="command-list" data-slot="command-list"
className={cn( className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CommandEmpty({ function CommandEmpty({
@ -107,7 +107,7 @@ function CommandEmpty({
className="py-6 text-center text-sm" className="py-6 text-center text-sm"
{...props} {...props}
/> />
) );
} }
function CommandGroup({ function CommandGroup({
@ -119,11 +119,11 @@ function CommandGroup({
data-slot="command-group" data-slot="command-group"
className={cn( className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CommandSeparator({ function CommandSeparator({
@ -136,7 +136,7 @@ function CommandSeparator({
className={cn("bg-border -mx-1 h-px", className)} className={cn("bg-border -mx-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function CommandItem({ function CommandItem({
@ -148,11 +148,11 @@ function CommandItem({
data-slot="command-item" data-slot="command-item"
className={cn( className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CommandShortcut({ function CommandShortcut({
@ -164,11 +164,11 @@ function CommandShortcut({
data-slot="command-shortcut" data-slot="command-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -181,4 +181,4 @@ export {
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, CommandSeparator,
} };

View File

@ -0,0 +1,148 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DevDialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dev-dialog" {...props} />;
}
function DevDialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dev-dialog-trigger" {...props} />;
}
function DevDialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dev-dialog-portal" {...props} />;
}
function DevDialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dev-dialog-close" {...props} />;
}
function DevDialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dev-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DevDialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DevDialogPortal data-slot="dev-dialog-portal">
<DevDialogOverlay />
<DialogPrimitive.Content
data-slot="dev-dialog-content"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-[400px] max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-[#ffffff] p-[40px] shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dev-dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DevDialogPortal>
);
}
function DevDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dev-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DevDialogFooter({
className,
singleColumn = false,
...props
}: React.ComponentProps<"div"> & { singleColumn?: boolean }) {
return (
<div
data-slot="dev-dialog-footer"
className={cn(
"grid w-full justify-between gap-[30px] sm:flex-row",
singleColumn ? "grid-cols-1" : "grid-cols-2",
className,
)}
{...props}
/>
);
}
function DevDialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dev-dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DevDialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dev-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
DevDialog,
DevDialogClose,
DevDialogContent,
DevDialogDescription,
DevDialogFooter,
DevDialogHeader,
DevDialogOverlay,
DevDialogPortal,
DevDialogTitle,
DevDialogTrigger,
};

View File

@ -1,33 +1,33 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@ -39,11 +39,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@ -61,7 +61,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@ -77,7 +77,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -96,11 +96,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@ -113,7 +113,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@ -126,7 +126,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -140,4 +140,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@ -17,18 +17,20 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return ( return (
<DropdownMenuPrimitive.Trigger <DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
className={cn(className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@ -42,13 +44,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[20px] border p-[20px] shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@ -56,7 +58,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
@ -65,8 +67,8 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@ -75,11 +77,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@ -93,7 +95,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@ -105,7 +107,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@ -116,7 +118,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@ -129,7 +131,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@ -140,7 +142,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@ -148,7 +150,7 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@ -156,11 +158,11 @@ function DropdownMenuLabel({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@ -173,7 +175,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
@ -185,17 +187,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@ -204,7 +206,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@ -212,14 +214,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@ -230,12 +232,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[20px] border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -254,4 +256,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@ -0,0 +1,55 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface DropdownSelectorOption<T extends string> {
value: T;
label: string;
}
interface DropdownSelectorProps<T extends string> {
value: T;
options: DropdownSelectorOption<T>[];
onChange: (value: T) => void;
triggerClassName?: string;
contentClassName?: string;
}
export function DropdownSelector<T extends string>({
value,
options,
onChange,
triggerClassName,
contentClassName,
}: DropdownSelectorProps<T>) {
const selectedOption = options.find((opt) => opt.value === value);
return (
<DropdownMenu>
<DropdownMenuTrigger
className={
triggerClassName ??
"border-none bg-transparent shadow-none select-none focus:outline-none"
}
>
{selectedOption?.label ?? value}
</DropdownMenuTrigger>
<DropdownMenuContent className={contentClassName}>
<DropdownMenuRadioGroup
value={value}
onValueChange={(v) => onChange(v as T)}
>
{options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,6 +1,6 @@
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) { function Empty({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -8,11 +8,11 @@ function Empty({ className, ...props }: React.ComponentProps<"div">) {
data-slot="empty" data-slot="empty"
className={cn( className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12", "flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -21,11 +21,11 @@ function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="empty-header" data-slot="empty-header"
className={cn( className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center", "flex max-w-sm flex-col items-center gap-2 text-center",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
const emptyMediaVariants = cva( const emptyMediaVariants = cva(
@ -40,8 +40,8 @@ const emptyMediaVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function EmptyMedia({ function EmptyMedia({
className, className,
@ -55,7 +55,7 @@ function EmptyMedia({
className={cn(emptyMediaVariants({ variant, className }))} className={cn(emptyMediaVariants({ variant, className }))}
{...props} {...props}
/> />
) );
} }
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -65,7 +65,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-lg font-medium tracking-tight", className)} className={cn("text-lg font-medium tracking-tight", className)}
{...props} {...props}
/> />
) );
} }
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
@ -74,11 +74,11 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
data-slot="empty-description" data-slot="empty-description"
className={cn( className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", "text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
@ -87,11 +87,11 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="empty-content" data-slot="empty-content"
className={cn( className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance", "flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -101,4 +101,4 @@ export {
EmptyDescription, EmptyDescription,
EmptyContent, EmptyContent,
EmptyMedia, EmptyMedia,
} };

View File

@ -1,14 +1,14 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card" import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function HoverCard({ function HoverCard({
...props ...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) { }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} /> return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
} }
function HoverCardTrigger({ function HoverCardTrigger({
@ -16,7 +16,7 @@ function HoverCardTrigger({
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) { }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return ( return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} /> <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
) );
} }
function HoverCardContent({ function HoverCardContent({
@ -33,12 +33,12 @@ function HoverCardContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</HoverCardPrimitive.Portal> </HoverCardPrimitive.Portal>
) );
} }
export { HoverCard, HoverCardTrigger, HoverCardContent } export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -14,20 +14,20 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
"group/input-group border-input/50 dark:bg-background/80 relative flex w-full items-center rounded-md border bg-white/80 shadow-xs transition-[color,box-shadow] outline-none", "group/input-group dark:bg-background/80 relative flex w-full max-w-[720px] items-center overflow-hidden rounded-md bg-[#FBFAFC] shadow-[0_0_20px_0_rgba(0,0,0,0.10)] transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto", "h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2", "has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2", "has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", "has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state. // Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-input has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", "has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state. // Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className, className,
)} )}
@ -152,7 +152,7 @@ function InputGroupTextarea({
<Textarea <Textarea
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent", "flex-1 resize-none rounded-none border-0 bg-transparent p-[20px] shadow-none focus-visible:ring-0 dark:bg-transparent",
className, className,
)} )}
{...props} {...props}

View File

@ -1,9 +1,9 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -13,7 +13,7 @@ function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("group/item-group flex flex-col", className)} className={cn("group/item-group flex flex-col", className)}
{...props} {...props}
/> />
) );
} }
function ItemSeparator({ function ItemSeparator({
@ -27,7 +27,7 @@ function ItemSeparator({
className={cn("my-0", className)} className={cn("my-0", className)}
{...props} {...props}
/> />
) );
} }
const itemVariants = cva( const itemVariants = cva(
@ -48,8 +48,8 @@ const itemVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Item({ function Item({
className, className,
@ -59,7 +59,7 @@ function Item({
...props ...props
}: React.ComponentProps<"div"> & }: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) { VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
data-slot="item" data-slot="item"
@ -68,7 +68,7 @@ function Item({
className={cn(itemVariants({ variant, size, className }))} className={cn(itemVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
const itemMediaVariants = cva( const itemMediaVariants = cva(
@ -85,8 +85,8 @@ const itemMediaVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function ItemMedia({ function ItemMedia({
className, className,
@ -100,7 +100,7 @@ function ItemMedia({
className={cn(itemMediaVariants({ variant, className }))} className={cn(itemMediaVariants({ variant, className }))}
{...props} {...props}
/> />
) );
} }
function ItemContent({ className, ...props }: React.ComponentProps<"div">) { function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
@ -109,11 +109,11 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-content" data-slot="item-content"
className={cn( className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", "flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -122,11 +122,11 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-title" data-slot="item-title"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium", "flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
@ -136,11 +136,11 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
className={cn( className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance", "text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemActions({ className, ...props }: React.ComponentProps<"div">) { function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
@ -150,7 +150,7 @@ function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center gap-2", className)} className={cn("flex items-center gap-2", className)}
{...props} {...props}
/> />
) );
} }
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -159,11 +159,11 @@ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-header" data-slot="item-header"
className={cn( className={cn(
"flex basis-full items-center justify-between gap-2", "flex basis-full items-center justify-between gap-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -172,11 +172,11 @@ function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="item-footer" data-slot="item-footer"
className={cn( className={cn(
"flex basis-full items-center justify-between gap-2", "flex basis-full items-center justify-between gap-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -190,4 +190,4 @@ export {
ItemDescription, ItemDescription,
ItemHeader, ItemHeader,
ItemFooter, ItemFooter,
} };

View File

@ -145,7 +145,7 @@
/* Border glow effect */ /* Border glow effect */
.magic-bento-card--border-glow::after { .magic-bento-card--border-glow::after {
content: ''; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
padding: 6px; padding: 6px;
@ -186,7 +186,7 @@
} }
.particle::before { .particle::before {
content: ''; content: "";
position: absolute; position: absolute;
top: -2px; top: -2px;
left: -2px; left: -2px;

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Progress({ function Progress({
className, className,
@ -15,7 +15,7 @@ function Progress({
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
> >
@ -25,7 +25,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };

View File

@ -39,7 +39,7 @@ function ResizableHandle({
<ResizablePrimitive.Separator <ResizablePrimitive.Separator
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90", "focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className, className,
)} )}
{...props} {...props}

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function ScrollArea({ function ScrollArea({
className, className,
@ -25,7 +25,7 @@ function ScrollArea({
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
) );
} }
function ScrollBar({ function ScrollBar({
@ -43,7 +43,7 @@ function ScrollBar({
"h-full w-2.5 border-l border-l-transparent", "h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent", "h-2.5 flex-col border-t border-t-transparent",
className className,
)} )}
{...props} {...props}
> >
@ -52,7 +52,7 @@ function ScrollBar({
className="bg-border relative flex-1 rounded-full" className="bg-border relative flex-1 rounded-full"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
) );
} }
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar };

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Separator({ function Separator({
className, className,
@ -18,11 +18,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@ -1,31 +1,31 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@ -50,7 +50,7 @@ function SheetContent({
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@ -67,7 +67,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@ -78,7 +78,7 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -88,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -98,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@ -111,7 +111,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@ -124,7 +124,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -136,4 +136,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@ -1,25 +1,25 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> { interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
/** /**
* Width of the border in pixels * Width of the border in pixels
* @default 1 * @default 1
*/ */
borderWidth?: number borderWidth?: number;
/** /**
* Duration of the animation in seconds * Duration of the animation in seconds
* @default 14 * @default 14
*/ */
duration?: number duration?: number;
/** /**
* Color of the border, can be a single color or an array of colors * Color of the border, can be a single color or an array of colors
* @default "#000000" * @default "#000000"
*/ */
shineColor?: string | string[] shineColor?: string | string[];
} }
/** /**
@ -55,9 +55,9 @@ export function ShineBorder({
} }
className={cn( className={cn(
"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]", "motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }

View File

@ -139,7 +139,7 @@ function SidebarProvider({
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar m-auto flex min-h-svh w-full overflow-hidden rounded-t-[20px]",
className, className,
)} )}
{...props} {...props}
@ -207,6 +207,7 @@ function Sidebar({
return ( return (
<div <div
// !
className="group peer text-sidebar-foreground hidden md:block" className="group peer text-sidebar-foreground hidden md:block"
data-state={state} data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""} data-collapsible={state === "collapsed" ? collapsible : ""}
@ -309,7 +310,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className, className,
)} )}

View File

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };

View File

@ -1,4 +1,4 @@
"use client" "use client";
import { import {
CircleCheckIcon, CircleCheckIcon,
@ -6,23 +6,23 @@ import {
Loader2Icon, Loader2Icon,
OctagonXIcon, OctagonXIcon,
TriangleAlertIcon, TriangleAlertIcon,
} from "lucide-react" } from "lucide-react";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
icons={{ icons={{
success: <CircleCheckIcon className="size-4" />, success: null,
info: <InfoIcon className="size-4" />, info: null,
warning: <TriangleAlertIcon className="size-4" />, warning: null,
error: <OctagonXIcon className="size-4" />, error: null,
loading: <Loader2Icon className="size-4 animate-spin" />, loading: null,
}} }}
style={ style={
{ {
@ -34,7 +34,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

View File

@ -11,13 +11,17 @@
} }
.card-spotlight::before { .card-spotlight::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: radial-gradient(circle at var(--mouse-x) var(--mouse-y), var(--spotlight-color), transparent 80%); background: radial-gradient(
circle at var(--mouse-x) var(--mouse-y),
var(--spotlight-color),
transparent 80%
);
opacity: 0; opacity: 0;
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
pointer-events: none; pointer-events: none;

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch" import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Switch({ function Switch({
className, className,
@ -14,18 +14,18 @@ function Switch({
data-slot="switch" data-slot="switch"
className={cn( className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className={cn( className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0" "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)} )}
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
) );
} }
export { Switch } export { Switch };

View File

@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Tabs({ function Tabs({
className, className,
@ -18,11 +18,11 @@ function Tabs({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
const tabsListVariants = cva( const tabsListVariants = cva(
@ -37,8 +37,8 @@ const tabsListVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function TabsList({ function TabsList({
className, className,
@ -53,7 +53,7 @@ function TabsList({
className={cn(tabsListVariants({ variant }), className)} className={cn(tabsListVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function TabsTrigger({ function TabsTrigger({
@ -68,11 +68,11 @@ function TabsTrigger({
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100", "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TabsContent({ function TabsContent({
@ -85,7 +85,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)} className={cn("flex-1 outline-none", className)}
{...props} {...props}
/> />
) );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

View File

@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle" import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const toggleVariants = cva( const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
@ -25,8 +25,8 @@ const toggleVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Toggle({ function Toggle({
className, className,
@ -41,7 +41,7 @@ function Toggle({
className={cn(toggleVariants({ variant, size, className }))} className={cn(toggleVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Toggle, toggleVariants } export { Toggle, toggleVariants };

View File

@ -46,7 +46,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset ?? 4} sideOffset={sideOffset ?? 4}
className={cn( className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]", "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-tooltip-background text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
className, className,
)} )}
{...props} {...props}

View File

@ -7,8 +7,18 @@ import {
PackageIcon, PackageIcon,
SquareArrowOutUpRightIcon, SquareArrowOutUpRightIcon,
XIcon, XIcon,
ZoomIn,
ZoomOut,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type HTMLAttributes,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
@ -20,6 +30,7 @@ import {
ArtifactHeader, ArtifactHeader,
ArtifactTitle, ArtifactTitle,
} from "@/components/ai-elements/artifact"; } from "@/components/ai-elements/artifact";
import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { Select, SelectItem } from "@/components/ui/select"; import { Select, SelectItem } from "@/components/ui/select";
import { import {
SelectContent, SelectContent,
@ -54,6 +65,7 @@ export function ArtifactFileDetail({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const [fullscreen, setFullscreen] = useState(false);
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@ -90,8 +102,42 @@ export function ArtifactFileDetail({
const displayContent = content ?? ""; const displayContent = content ?? "";
const artifactOptions = useMemo(() => {
return (artifacts ?? []).map((artifactPath) => ({
value: artifactPath,
label: getFileName(artifactPath),
}));
}, [artifacts]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(100);
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.error("无法进入全屏模式:", err);
});
setFullscreen(true);
} else {
document.exitFullscreen().catch((err) => {
console.error("无法退出全屏模式:", err);
});
setFullscreen(false);
}
}, [setFullscreen]);
// 监听全屏变化
useEffect(() => {
const handleFullscreenChange = () => {
setFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, [setFullscreen]);
useEffect(() => { useEffect(() => {
if (previewable) { if (previewable) {
@ -124,40 +170,19 @@ export function ArtifactFileDetail({
}, [threadId, filepath, isInstalling]); }, [threadId, filepath, isInstalling]);
return ( return (
<Artifact className={cn(className)}> <Artifact className={cn(className)}>
<ArtifactHeader className="px-2"> <ArtifactHeader>
<div className="flex items-center gap-2"> <div className="flex items-center justify-start gap-2">
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
) : (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((filepath) => (
<SelectItem key={filepath} value={filepath}>
{getFileName(filepath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</ArtifactTitle>
</div>
<div className="flex min-w-0 grow items-center justify-center">
{previewable && ( {previewable && (
<ToggleGroup <ToggleGroup
className="mx-auto"
type="single" type="single"
variant="outline" variant="outline"
size="sm" size="sm"
value={viewMode} value={viewMode}
onValueChange={(value) => onValueChange={(value) => {
setViewMode(value as "code" | "preview") if (value) {
} setViewMode(value as "code" | "preview");
}
}}
> >
<ToggleGroupItem value="code"> <ToggleGroupItem value="code">
<Code2Icon /> <Code2Icon />
@ -168,34 +193,25 @@ export function ArtifactFileDetail({
</ToggleGroup> </ToggleGroup>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
) : (
<DropdownSelector
value={filepath}
options={artifactOptions}
onChange={select}
/>
)}
</ArtifactTitle>
</div>
<div className="flex items-center justify-end overflow-hidden">
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
<ArtifactActions> <ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={
isInstalling ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={handleInstallSkill}
/>
</Tooltip>
)}
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
/>
</a>
)}
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
icon={CopyIcon}
label={t.clipboard.copyToClipboard} label={t.clipboard.copyToClipboard}
disabled={!content} disabled={!content}
onClick={async () => { onClick={async () => {
@ -208,7 +224,30 @@ export function ArtifactFileDetail({
} }
}} }}
tooltip={t.clipboard.copyToClipboard} tooltip={t.clipboard.copyToClipboard}
/> >
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2H13C14.1046 2 15 2.89543 15 4V13"
stroke="#666666"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
x="2.5"
y="4.5"
width="10"
height="11"
rx="1.5"
stroke="#666666"
/>
</svg>
</ArtifactAction>
)} )}
{!isWriteFile && ( {!isWriteFile && (
<a <a
@ -216,36 +255,128 @@ export function ArtifactFileDetail({
target="_blank" target="_blank"
> >
<ArtifactAction <ArtifactAction
icon={DownloadIcon}
label={t.common.download} label={t.common.download}
tooltip={t.common.download} tooltip={t.common.download}
/> >
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
stroke="#666666"
stroke-linecap="round"
/>
<path
d="M9 2V13M9 13L5 9M9 13L13 9"
stroke="#666666"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</ArtifactAction>
</a> </a>
)} )}
{/* 全屏按钮 */}
<ArtifactAction
label={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
onClick={handleFullscreenToggle}
tooltip={
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
}
>
{fullscreen ? (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 2V4C6 5.10457 5.10457 6 4 6H2"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6 16V14C6 12.8954 5.10457 12 4 12H2"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 2V4C12 5.10457 12.8954 6 14 6H16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 16V14C12 12.8954 12.8954 12 14 12H16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.75 15.5H4.5C3.39543 15.5 2.5 14.6046 2.5 13.5V12.25M2.5 5.75V4.5C2.5 3.39543 3.39543 2.5 4.5 2.5H5.75M12.25 2.5H13.5C14.6046 2.5 15.5 3.39543 15.5 4.5V5.75M15.5 12.25V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H12.25"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
)}
</ArtifactAction>
<ArtifactAction <ArtifactAction
icon={XIcon}
label={t.common.close} label={t.common.close}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
tooltip={t.common.close} tooltip={t.common.close}
/> >
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
stroke-linecap="round"
/>
</svg>
</ArtifactAction>
</ArtifactActions> </ArtifactActions>
</div> </div>
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent className="p-0"> <ArtifactContent className="rounded-[10px] bg-white p-0">
{previewable && {previewable &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
filepath={filepath}
threadId={threadId}
content={displayContent} content={displayContent}
language={language ?? "text"} language={language ?? "text"}
zoom={zoom}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none" className="size-full resize-none rounded-none border-none"
value={displayContent ?? ""} value={displayContent ?? ""}
zoom={zoom}
readonly readonly
/> />
)} )}
@ -261,19 +392,22 @@ export function ArtifactFileDetail({
} }
export function ArtifactFilePreview({ export function ArtifactFilePreview({
filepath,
threadId,
content, content,
language, language,
zoom = 100,
}: { }: {
filepath: string;
threadId: string;
content: string; content: string;
language: string; language: string;
zoom?: number;
}) { }) {
const zoomScale = zoom / 100;
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div className="size-full px-4"> <div
className={cn("size-full p-[20px]")}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
>
<Streamdown <Streamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
@ -288,10 +422,98 @@ export function ArtifactFilePreview({
return ( return (
<iframe <iframe
className="size-full" className="size-full"
src={urlOfArtifact({ filepath, threadId })} title="Artifact preview"
srcDoc={content}
sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }}
/> />
); );
} }
return null; return null;
} }
// 缩放比例选项
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];
export type ArtifactZoomSelectorProps = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
> & {
value?: number;
onChange?: (value: number) => void;
};
export const ArtifactZoomSelector = ({
value = 100,
onChange,
className,
...props
}: ArtifactZoomSelectorProps) => {
const handleZoomIn = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const nextValue = ZOOM_LEVELS[currentIndex + 1];
if (currentIndex < ZOOM_LEVELS.length - 1 && nextValue !== undefined) {
onChange?.(nextValue);
}
};
const handleZoomOut = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const prevValue = ZOOM_LEVELS[currentIndex - 1];
if (currentIndex > 0 && prevValue !== undefined) {
onChange?.(prevValue);
}
};
const canZoomIn = ZOOM_LEVELS.indexOf(value) < ZOOM_LEVELS.length - 1;
const canZoomOut = ZOOM_LEVELS.indexOf(value) > 0;
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-[10px] bg-white px-2 py-1 backdrop-blur-sm",
"border border-gray-200/50",
"dark:border-gray-700/50 dark:bg-gray-800/90",
className,
)}
{...props}
>
<button
type="button"
onClick={handleZoomIn}
disabled={!canZoomIn}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="放大"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<span
className={cn(
"min-w-[42px] text-center text-xs font-medium text-gray-600",
"dark:text-gray-300",
)}
>
{value}%
</span>
<button
type="button"
onClick={handleZoomOut}
disabled={!canZoomOut}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="缩小"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
</div>
);
};

View File

@ -81,9 +81,9 @@ export function ArtifactFileList({
> >
<CardHeader className="pr-2 pl-1"> <CardHeader className="pr-2 pl-1">
<CardTitle className="relative pl-8"> <CardTitle className="relative pl-8">
<div>{getFileName(file)}</div> <div className="text-sm font-normal">{getFileName(file)}</div>
<div className="absolute top-2 -left-0.5"> <div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6")} {getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
</div> </div>
</CardTitle> </CardTitle>
<CardDescription className="pl-8 text-xs"> <CardDescription className="pl-8 text-xs">

View File

@ -15,6 +15,9 @@ export interface ArtifactsContextType {
open: boolean; open: boolean;
autoOpen: boolean; autoOpen: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
fullscreen: boolean;
setFullscreen: (fullscreen: boolean) => void;
} }
const ArtifactsContext = createContext<ArtifactsContextType | undefined>( const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
@ -33,6 +36,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const select = (artifact: string, autoSelect = false) => { const select = (artifact: string, autoSelect = false) => {
@ -68,6 +72,9 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifact, selectedArtifact,
select, select,
deselect, deselect,
fullscreen,
setFullscreen,
}; };
return ( return (

View File

@ -48,12 +48,12 @@ export function CitationLink({
<div className="p-3"> <div className="p-3">
<div className="space-y-1"> <div className="space-y-1">
{displayText && ( {displayText && (
<h4 className="truncate font-medium text-sm leading-tight"> <h4 className="truncate text-sm leading-tight font-medium">
{displayText} {displayText}
</h4> </h4>
)} )}
{href && ( {href && (
<p className="truncate break-all text-muted-foreground text-xs"> <p className="text-muted-foreground truncate text-xs break-all">
{href} {href}
</p> </p>
)} )}

View File

@ -42,6 +42,7 @@ export function CodeEditor({
disabled, disabled,
autoFocus, autoFocus,
settings, settings,
zoom = 100,
}: { }: {
className?: string; className?: string;
placeholder?: string; placeholder?: string;
@ -50,6 +51,7 @@ export function CodeEditor({
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
settings?: unknown; settings?: unknown;
zoom?: number;
}) { }) {
const { const {
thread: { isLoading }, thread: { isLoading },
@ -70,12 +72,14 @@ export function CodeEditor({
]; ];
}, []); }, []);
const zoomScale = (zoom ?? 100) / 100;
return ( return (
<div <div
className={cn( className={cn(
"flex cursor-text flex-col overflow-hidden rounded-md", "flex cursor-text flex-col overflow-hidden rounded-md",
className, className,
)} )}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
> >
{isLoading ? ( {isLoading ? (
<Textarea <Textarea

View File

@ -0,0 +1,69 @@
"use client";
import type { Todo } from "@/core/todos";
import { cn } from "@/lib/utils";
import {
QueueItem,
QueueItemContent,
QueueItemIndicator,
QueueList,
} from "../ai-elements/queue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
export function DevTodoList({
className,
todos,
trigger,
hidden,
}: {
className?: string;
todos: Todo[];
trigger: React.ReactNode;
hidden: boolean;
}) {
if (hidden) {
return null;
}
console.log(todos);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent
className={cn(
"z-[100] rounded-[20px] bg-white p-5 shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
className,
)}
align="start"
side="top"
>
<QueueList className="w-64">
{todos.map((todo, i) => (
<QueueItem key={i + (todo.content ?? "")}>
<div className="flex items-center gap-2">
<QueueItemIndicator
className={
todo.status === "in_progress" ? "bg-primary/70" : ""
}
completed={todo.status === "completed"}
/>
<QueueItemContent
className={
todo.status === "in_progress" ? "text-primary/70" : ""
}
completed={todo.status === "completed"}
>
{todo.content}
</QueueItemContent>
</div>
</QueueItem>
))}
</QueueList>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,226 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils";
/**
* IframeTestPanel iframe
*
*
* 1. mode=skill
* 2. useSpecificChatMode
* 3. sendSelectSkill / openSkillDialog / clearSkill
*/
export function IframeTestPanel() {
const router = useRouter();
const searchParams = useSearchParams();
const iframeSkill = useIframeSkill();
const [log, setLog] = useState<string[]>([]);
const [open, setOpen] = useState(true);
const isSkillMode = searchParams.get("mode") === "skill";
function addLog(msg: string) {
setLog((prev) => [
`[${new Date().toLocaleTimeString()}] ${msg}`,
...prev.slice(0, 9),
]);
}
function handleEnterSkillMode() {
router.push(`?mode=skill&skill_id=123&title=测试技能`);
addLog("进入 mode=skillURL 已更新");
}
function handleExitSkillMode() {
router.push(`?`);
addLog("退出 skill 模式");
}
function handleSendSelectSkill() {
iframeSkill.sendSelectSkill("skill_001");
addLog("postMessage → selectSkill (skill_id=skill_001)");
}
function handleOpenSkillDialog() {
iframeSkill.openSkillDialog();
addLog("postMessage → openSkillDialog");
}
function handleClearSkill() {
iframeSkill.clearSkill();
addLog("clearSkill 已调用postMessage → skill_id=0");
}
if (!open) {
return (
<button
className="fixed bottom-24 left-3 z-[9999] rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white shadow-lg hover:bg-violet-600"
onClick={() => setOpen(true)}
>
🧪
</button>
);
}
return (
<div className="fixed bottom-24 left-3 z-[9999] w-72 rounded-xl border border-violet-200 bg-white/95 shadow-2xl backdrop-blur-sm">
{/* 标题栏 */}
<div className="flex items-center justify-between rounded-t-xl bg-violet-500 px-3 py-2">
<span className="text-xs font-bold text-white">🧪 iframe </span>
<button
className="text-white/70 hover:text-white"
onClick={() => setOpen(false)}
>
</button>
</div>
<div className="space-y-3 p-3">
{/* 当前状态 */}
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
<div className="mb-1 font-semibold text-gray-500"></div>
<div className="flex flex-col gap-1">
<span>
<span className="text-gray-400">mode</span>
<span
className={cn(
"font-mono font-bold",
isSkillMode ? "text-violet-600" : "text-gray-400",
)}
>
{isSkillMode ? "skill ✅" : "普通"}
</span>
</span>
<span>
<span className="text-gray-400">selectedSkill</span>
<span className="font-mono text-violet-600">
{iframeSkill.selectedSkill
? `${iframeSkill.selectedSkill.skill_id} / ${iframeSkill.selectedSkill.title}`
: "无"}
</span>
</span>
</div>
</div>
{/* 场景 1侧边栏隐藏 */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
layout
</div>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 text-xs"
variant="outline"
onClick={handleEnterSkillMode}
>
skill
</Button>
<Button
size="sm"
className="flex-1 text-xs"
variant="outline"
onClick={handleExitSkillMode}
>
退 skill
</Button>
</div>
</div>
{/* 场景 2skill 选择通信 */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
postMessage 宿
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
variant="ghost"
onClick={handleSendSelectSkill}
>
sendSelectSkill (skill_001)
</Button>
<Button
size="sm"
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
variant="ghost"
onClick={handleOpenSkillDialog}
>
openSkillDialog
</Button>
<Button
size="sm"
className="w-full bg-red-50 text-xs text-red-600 hover:bg-red-100"
variant="ghost"
onClick={handleClearSkill}
>
clearSkill ( skill_id=0)
</Button>
</div>
</div>
{/* 场景 3接收宿主页 selectedSkill */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
宿 selectedSkill
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-green-50 text-xs text-green-700 hover:bg-green-100"
variant="ghost"
onClick={() => {
window.postMessage(
{ type: "selectedSkill", id: 5, title: "文档处理" },
"*",
);
addLog(
"模拟宿主页 → selectedSkill { id: 5, title: '文档处理' }",
);
}}
>
selectedSkill
</Button>
<Button
size="sm"
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
variant="ghost"
onClick={() => {
window.postMessage(
{ type: "selectedSkill", id: 999999, title: "不存在的技能" },
"*",
);
addLog(
"模拟宿主页 → selectedSkill { id: 999999, title: '不存在的技能' }",
);
}}
>
selectedSkill/
</Button>
</div>
</div>
{/* 日志 */}
{log.length > 0 && (
<div className="rounded-lg bg-gray-900 p-2">
<div className="mb-1 text-[10px] font-semibold text-gray-400">
</div>
{log.map((l, i) => (
<div
key={i}
className="truncate font-mono text-[10px] text-green-400"
>
{l}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -9,10 +9,18 @@ import {
PlusIcon, PlusIcon,
SparklesIcon, SparklesIcon,
RocketIcon, RocketIcon,
XIcon,
ZapIcon, ZapIcon,
} from "lucide-react"; } from "lucide-react";
import { useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState, type ComponentProps } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import { import {
PromptInput, PromptInput,
@ -32,7 +40,17 @@ import {
usePromptInputController, usePromptInputController,
type PromptInputMessage, type PromptInputMessage,
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfettiButton } from "@/components/ui/confetti-button"; import { ConfettiButton } from "@/components/ui/confetti-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuLabel,
@ -41,6 +59,7 @@ import {
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -102,6 +121,42 @@ export function InputBox({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const iframeSkill = useIframeSkill();
const params = useParams();
const threadId = params?.thread_id;
const { textInput } = usePromptInputController();
const attachments = usePromptInputAttachments();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
const [isFocused, setIsFocused] = useState(false);
// isNewThread 时禁用收缩,始终保持展开
const effectiveIsFocused = (isNewThread ?? false) || isFocused;
// 点击外部区域时收起输入框
useEffect(() => {
if (!isFocused) return;
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsFocused(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isFocused]);
const [modelDialogOpen, setModelDialogOpen] = useState(false); const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels(); const { models } = useModels();
const selectedModel = useMemo(() => { const selectedModel = useMemo(() => {
@ -154,40 +209,143 @@ export function InputBox({
}, },
[onSubmit, onStop, status], [onSubmit, onStop, status],
); );
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
}, []);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") return;
const current = (textInput?.value ?? "").trim();
if (current) {
setPendingSuggestion(suggestion);
setConfirmOpen(true);
return;
}
textInput?.setInput(suggestion);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
if (!pendingSuggestion) return;
textInput?.setInput(pendingSuggestion);
setFollowupsHidden(true);
setConfirmOpen(false);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const confirmAppendAndSend = useCallback(() => {
if (!pendingSuggestion) return;
const current = (textInput?.value ?? "").trim();
textInput?.setInput(
current ? `${current}\n${pendingSuggestion}` : pendingSuggestion,
);
setFollowupsHidden(true);
setConfirmOpen(false);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
useEffect(() => {
if (!threadId || isNewThread || disabled) return;
const controller = new AbortController();
setFollowupsHidden(false);
setFollowupsLoading(true);
setFollowups([]);
fetch(`/api/threads/${String(threadId)}/suggestions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [], n: 3 }),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) return { suggestions: [] };
return await res.json();
})
.then((data) => {
const suggestions = (data.suggestions ?? [])
.filter(Boolean)
.slice(0, 5);
setFollowups(suggestions);
})
.catch(() => setFollowups([]))
.finally(() => setFollowupsLoading(false));
return () => controller.abort();
}, [disabled, isNewThread, threadId]);
return ( return (
<PromptInput <div
className={cn( ref={(el) => {
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl", promptRootRef.current = el;
className, containerRef.current = el;
)} }}
disabled={disabled} className="relative w-full"
globalDrop
multiple
onSubmit={handleSubmit}
{...props}
> >
<AttachmentPreviewBar />
{extraHeader && ( {extraHeader && (
<div className="absolute top-0 right-0 left-0 z-10"> <ExtraHeaderContainer hasAttachments={attachments.files.length > 0}>
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center"> {extraHeader}
{extraHeader} </ExtraHeaderContainer>
</div>
</div>
)} )}
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />} <PromptInput
</PromptInputAttachments> className={cn(
<PromptInputBody className="absolute top-0 right-0 left-0 z-3"> "bg-background w-full rounded-2xl transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
<PromptInputTextarea className,
className={cn("size-full")} )}
disabled={disabled} inputGroupClassName={cn(
placeholder={t.inputBox.placeholder} "border-0 rounded-[20px] backdrop-blur-sm",
autoFocus={autoFocus} "transition-[height] duration-300 ease-out",
defaultValue={initialValue} !isNewThread && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
/> effectiveIsFocused ? "h-[200px]" : "h-[80px]",
</PromptInputBody> )}
<PromptInputFooter className="flex"> disabled={disabled}
<PromptInputTools> globalDrop
{/* TODO: Add more connectors here multiple
onSubmit={handleSubmit}
{...props}
>
<PromptInputBody
className={cn("transition-[opacity,transform] duration-300 ease-out")}
>
<PromptInputTextarea
ref={textareaRef}
className={cn(
"size-full",
!effectiveIsFocused && "h-[80px] py-0 leading-20",
)}
disabled={disabled}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
onFocus={() => setIsFocused(true)}
/>
</PromptInputBody>
{!effectiveIsFocused && (
<div
className="absolute inset-0 z-1 cursor-text"
onClick={() => {
setIsFocused(true);
textareaRef.current?.focus();
}}
/>
)}
<PromptInputFooter
className={cn(
"flex transition-all duration-300 ease-out",
!effectiveIsFocused &&
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
)}
>
<PromptInputTools>
{/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" /> <PromptInputActionMenuTrigger className="px-2!" />
<PromptInputActionMenuContent> <PromptInputActionMenuContent>
@ -196,243 +354,131 @@ export function InputBox({
/> />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
<AddAttachmentsButton className="px-2!" /> <AddAttachmentsButton className="px-2!" />
<PromptInputActionMenu> <IframeSkillDialogButton
<ModeHoverGuide className="px-2!"
mode={ selectedSkill={iframeSkill.selectedSkill}
context.mode === "flash" || openSkillDialog={iframeSkill.openSkillDialog}
context.mode === "thinking" || clearSkill={iframeSkill.clearSkill}
context.mode === "pro" || />
context.mode === "ultra" {/* 参考 kexue 版本隐藏运行模式切换按钮 */}
? context.mode </PromptInputTools>
: "flash" <PromptInputTools>
} {/* 占位符 */}
> <div className="w-[150px]"></div>
<PromptInputActionMenuTrigger className="gap-1! px-2!"> </PromptInputTools>
<div> </PromptInputFooter>
{context.mode === "flash" && <ZapIcon className="size-3" />} <PromptInputSubmit
{context.mode === "thinking" && ( className="absolute right-3 bottom-5 z-[20] border-0"
<LightbulbIcon className="size-3" /> disabled={disabled}
)} variant="outline"
{context.mode === "pro" && ( status={status}
<GraduationCapIcon className="size-3" /> />
)} </PromptInput>
{context.mode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" />
)}
</div>
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
)}
>
{(context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" && t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)}
</div>
</PromptInputActionMenuTrigger>
</ModeHoverGuide>
<PromptInputActionMenuContent className="w-80">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("flash")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
context.mode === "flash" &&
"text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.flashModeDescription}
</div>
</div>
{context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("thinking")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<LightbulbIcon
className={cn(
"mr-2 size-4",
context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
{t.inputBox.reasoningMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.reasoningModeDescription}
</div>
</div>
{context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("pro")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<GraduationCapIcon
className={cn(
"mr-2 size-4",
context.mode === "pro" && "text-accent-foreground",
)}
/>
{t.inputBox.proMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.proModeDescription}
</div>
</div>
{context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("ultra")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<RocketIcon
className={cn(
"mr-2 size-4",
context.mode === "ultra" && "text-[#dabb5e]",
)}
/>
<div
className={cn(
context.mode === "ultra" && "golden-text",
)}
>
{t.inputBox.ultraMode}
</div>
</div>
<div className="pl-7 text-xs">
{t.inputBox.ultraModeDescription}
</div>
</div>
{context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputTools>
<ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<PromptInputButton>
<ModelSelectorName className="text-xs font-normal">
{selectedModel?.display_name}
</ModelSelectorName>
</PromptInputButton>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList>
{models.map((m) => (
<ModelSelectorItem
key={m.name}
value={m.name}
onSelect={() => handleModelSelect(m.name)}
>
<ModelSelectorName>{m.display_name}</ModelSelectorName>
{m.name === context.model_name ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
<PromptInputSubmit
className="rounded-full"
disabled={disabled}
variant="outline"
status={status}
/>
</PromptInputTools>
</PromptInputFooter>
{isNewThread && searchParams.get("mode") !== "skill" && ( {isNewThread && searchParams.get("mode") !== "skill" && (
<div className="absolute right-0 -bottom-20 left-0 z-0 flex items-center justify-center"> <SuggestionListContainer
<SuggestionList /> sendSelectSkill={iframeSkill.sendSelectSkill}
</div> />
)} )}
{!isNewThread && (
<div className="bg-background absolute right-0 -bottom-[17px] left-0 z-0 h-4"></div> {!disabled &&
)} !isNewThread &&
</PromptInput> !followupsHidden &&
(followupsLoading || followups.length > 0) && (
<div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center">
<div className="flex items-center gap-2">
{followupsLoading ? (
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
...
</div>
) : (
<Suggestions className="min-h-16 w-fit items-start">
{followups.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={() => handleFollowupClick(s)}
/>
))}
<Button
aria-label={t.common.close}
className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal"
variant="outline"
size="sm"
type="button"
onClick={() => setFollowupsHidden(true)}
>
<XIcon className="size-4" />
</Button>
</Suggestions>
)}
</div>
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
</Button>
<Button variant="secondary" onClick={confirmAppendAndSend}>
</Button>
<Button onClick={confirmReplaceAndSend}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
); );
} }
function SuggestionList() { // SuggestionList 容器
function SuggestionListContainer({
sendSelectSkill,
}: {
sendSelectSkill: (skill_id: string) => void;
}) {
return (
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
<SuggestionList sendSelectSkill={sendSelectSkill} />
</div>
);
}
// 快速选择skillbutton
function SuggestionList({
sendSelectSkill,
}: {
sendSelectSkill: (skill_id: string) => void;
}) {
const { t } = useI18n(); const { t } = useI18n();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const handleSuggestionClick = useCallback( const handleSuggestionClick = useCallback(
(prompt: string | undefined) => { (suggestion: { prompt: string; skill_id?: string }) => {
if (!prompt) return; // 如果有 skill_id发送给宿主页
textInput.setInput(prompt); if (suggestion.skill_id) {
sendSelectSkill(suggestion.skill_id);
return;
}
// 原有逻辑
if (!suggestion.prompt) return;
textInput.setInput(suggestion.prompt);
setTimeout(() => { setTimeout(() => {
const textarea = document.querySelector<HTMLTextAreaElement>( const textarea = document.querySelector<HTMLTextAreaElement>(
"textarea[name='message']", "textarea[name='message']",
); );
if (textarea) { if (textarea) {
const selStart = prompt.indexOf("["); const selStart = suggestion.prompt.indexOf("[");
const selEnd = prompt.indexOf("]"); const selEnd = suggestion.prompt.indexOf("]");
if (selStart !== -1 && selEnd !== -1) { if (selStart !== -1 && selEnd !== -1) {
textarea.setSelectionRange(selStart, selEnd + 1); textarea.setSelectionRange(selStart, selEnd + 1);
textarea.focus(); textarea.focus();
@ -440,50 +486,18 @@ function SuggestionList() {
} }
}, 500); }, 500);
}, },
[textInput], [textInput, sendSelectSkill],
); );
return ( return (
<Suggestions className="min-h-16 w-fit items-start"> <Suggestions className="min-h-16 w-fit items-start">
<ConfettiButton
className="text-muted-foreground cursor-pointer rounded-full px-4 text-xs font-normal"
variant="outline"
size="sm"
onClick={() => handleSuggestionClick(t.inputBox.surpriseMePrompt)}
>
<SparklesIcon className="size-4" /> {t.inputBox.surpriseMe}
</ConfettiButton>
{t.inputBox.suggestions.map((suggestion) => ( {t.inputBox.suggestions.map((suggestion) => (
<Suggestion <Suggestion
key={suggestion.suggestion} key={suggestion.suggestion}
icon={suggestion.icon} icon={suggestion.icon}
suggestion={suggestion.suggestion} suggestion={suggestion.suggestion}
onClick={() => handleSuggestionClick(suggestion.prompt)} onClick={() => handleSuggestionClick(suggestion)}
/> />
))} ))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Suggestion icon={PlusIcon} suggestion={t.common.create} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
{t.inputBox.suggestionsCreate.map((suggestion, index) =>
"type" in suggestion && suggestion.type === "separator" ? (
<DropdownMenuSeparator key={index} />
) : (
!("type" in suggestion) && (
<DropdownMenuItem
key={suggestion.suggestion}
onClick={() => handleSuggestionClick(suggestion.prompt)}
>
{suggestion.icon && <suggestion.icon className="size-4" />}
{suggestion.suggestion}
</DropdownMenuItem>
)
),
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</Suggestions> </Suggestions>
); );
} }
@ -494,11 +508,115 @@ function AddAttachmentsButton({ className }: { className?: string }) {
return ( return (
<Tooltip content={t.inputBox.addAttachments}> <Tooltip content={t.inputBox.addAttachments}>
<PromptInputButton <PromptInputButton
className={cn("px-2!", className)} className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
onClick={() => attachments.openFileDialog()} onClick={() => attachments.openFileDialog()}
> >
<PaperclipIcon className="size-3" /> <svg
width="18"
height="15"
viewBox="0 0 18 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="transition-[stroke] duration-200 [&>path]:transition-[fill,stroke] [&>path]:duration-200 [&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]"
>
<path
d="M7.05042 7.65254C6.9754 7.72756 6.90039 7.80257 6.90039 7.95258C6.90039 8.02759 6.9754 8.1776 7.05042 8.25262C7.20043 8.40263 7.42545 8.40263 7.57546 8.25262L8.8506 6.97747V10.7279C8.8506 10.9529 9.00061 11.1029 9.22563 11.1029C9.30065 11.1029 9.45066 11.0279 9.52567 11.0279C9.60067 10.9529 9.67568 10.8779 9.67568 10.7279V6.97747L10.9508 8.25262C11.1008 8.40263 11.3259 8.40263 11.4759 8.25262C11.5509 8.1776 11.6259 8.10259 11.6259 7.95258C11.6259 7.87757 11.5509 7.72756 11.4759 7.65254L9.52567 5.70235C9.37564 5.55234 9.15062 5.55234 9.00061 5.70235L7.05042 7.65254Z"
fill="#150033"
/>
<path
d="M1.12695 0.5H6.67871C6.87077 0.500077 7.01409 0.574515 7.07324 0.648438L7.09082 0.669922L8.30762 1.88672C8.6222 2.20119 9.01344 2.3681 9.44629 2.36816H16.875C17.2382 2.36842 17.5012 2.63339 17.5 2.99414V13.8848C17.5048 14.2408 17.2454 14.5056 16.8818 14.5059H1.12695C0.764649 14.5057 0.5 14.2401 0.5 13.877V1.12793C0.500049 0.810129 0.702664 0.567404 0.996094 0.511719L1.12695 0.5Z"
stroke="#150033"
/>
</svg>
</PromptInputButton> </PromptInputButton>
</Tooltip> </Tooltip>
); );
} }
// 启动iframeSkillDialog
function IframeSkillDialogButton({
className,
selectedSkill,
openSkillDialog,
clearSkill,
}: {
className?: string;
selectedSkill: { skill_id: string; title: string } | null;
openSkillDialog: () => void;
clearSkill: () => void;
}) {
const { t } = useI18n();
return (
<div className="flex items-center gap-2">
<Tooltip content={t.inputBox.selectSkill}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
onClick={openSkillDialog}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="size-4 transition-[stroke] duration-200 [&>path]:transition-[stroke] [&>path]:duration-200 [&>path]:group-hover:stroke-[#8E47F0]"
viewBox="0 0 12 16"
fill="none"
>
<path
d="M3.7998 0.5H9.19922C9.24033 0.5 9.26852 0.518136 9.28516 0.541992C9.30124 0.565318 9.30411 0.588767 9.29395 0.613281H9.29297L7.43066 5.07422L7.1416 5.76758H11.3994C11.4295 5.76765 11.4474 5.77552 11.459 5.7832C11.4724 5.79207 11.4846 5.80503 11.4922 5.82129C11.4997 5.83745 11.5013 5.85253 11.5 5.86328C11.4989 5.87156 11.4953 5.88556 11.4785 5.9043L2.87891 15.4629V15.4639C2.85396 15.4914 2.83406 15.4971 2.82031 15.499C2.80144 15.5016 2.77553 15.4981 2.74902 15.4844C2.72225 15.4705 2.70837 15.453 2.70312 15.4424C2.70056 15.4372 2.69457 15.4253 2.70312 15.3936V15.3926L4.30273 9.49512L4.47461 8.86426H0.600586C0.559682 8.86424 0.531324 8.84587 0.514648 8.82227C0.498608 8.79944 0.496551 8.777 0.505859 8.75293L3.70508 0.558594C3.71075 0.544183 3.72173 0.529788 3.73828 0.518555C3.74688 0.51277 3.75704 0.508037 3.76758 0.504883L3.7998 0.5Z"
stroke="#150033"
/>
</svg>
</PromptInputButton>
</Tooltip>
{selectedSkill && (
<Badge
variant="secondary"
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]"
>
{selectedSkill.title}
<button
onClick={clearSkill}
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
>
<XIcon className="size-3" />
</button>
</Badge>
)}
</div>
);
}
// 附件预览栏 - 在输入框上方显示
function AttachmentPreviewBar() {
const attachments = usePromptInputAttachments();
if (!attachments.files.length) {
return null;
}
return (
<div className="absolute bottom-full left-0 z-20 mb-3 ml-1 flex justify-start">
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</div>
);
}
// ExtraHeader 容器 - 有附件时上浮
function ExtraHeaderContainer({
hasAttachments,
children,
}: {
hasAttachments: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"absolute right-0 bottom-full left-0 z-30 flex items-center justify-center pb-4 transition-transform duration-300",
hasAttachments && "-translate-y-20",
)}
>
{children}
</div>
);
}

View File

@ -79,7 +79,7 @@ export function MessageGroup({
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return ( return (
<ChainOfThought <ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)} className={cn("w-full gap-2 rounded-lg bg-white", className)}
open={true} open={true}
> >
{aboveLastToolCallSteps.length > 0 && ( {aboveLastToolCallSteps.length > 0 && (

View File

@ -19,8 +19,8 @@ import {
parseUploadedFiles, parseUploadedFiles,
type UploadedFile, type UploadedFile,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { materializeSkillYaml } from "@/core/skills";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { materializeSkillYaml } from "@/core/skills";
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@ -29,7 +29,10 @@ function getModeDescriptionKey(
mode: AgentMode, mode: AgentMode,
): keyof Pick< ): keyof Pick<
Translations["inputBox"], Translations["inputBox"],
"flashModeDescription" | "reasoningModeDescription" | "proModeDescription" | "ultraModeDescription" | "flashModeDescription"
| "reasoningModeDescription"
| "proModeDescription"
| "ultraModeDescription"
> { > {
switch (mode) { switch (mode) {
case "flash": case "flash":

View File

@ -33,17 +33,20 @@ DeerFlow is proudly open source and distributed under the **MIT License**.
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants. We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
### Core Frameworks ### Core Frameworks
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains. - **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration. - **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications. - **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
### UI Libraries ### UI Libraries
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI. - **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects. - **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration. These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
### Special Thanks ### Special Thanks
Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0: Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0:
- **[Daniel Walnut](https://github.com/hetaoBackend/)** - **[Daniel Walnut](https://github.com/hetaoBackend/)**

View File

@ -0,0 +1,42 @@
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef } from "react";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { useI18n } from "@/core/i18n/hooks";
/**
* URL Chat iframe
* prompt
*/
export function useSpecificChatMode() {
const { t } = useI18n();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const promptInputController = usePromptInputController();
const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined;
}
return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef(promptInputController.textInput.setInput);
setInputRef.current = promptInputController.textInput.setInput;
useEffect(() => {
if (
inputInitialValue &&
inputInitialValue !== lastInitialValueRef.current
) {
lastInitialValueRef.current = inputInitialValue;
setTimeout(() => {
setInputRef.current(inputInitialValue);
const textarea = document.querySelector("textarea");
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}
}, 100);
}
}, [inputInitialValue]);
}

View File

@ -41,10 +41,9 @@ export function Welcome({
`${t.welcome.createYourOwnSkill}` `${t.welcome.createYourOwnSkill}`
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={cn("inline-block", !waved ? "animate-wave" : "")}> <AuroraText className="text-[18px] text-[#150033]" colors={colors}>
{isUltra ? "🚀" : "👋"} {t.welcome.greeting}
</div> </AuroraText>
<AuroraText colors={colors}>{t.welcome.greeting}</AuroraText>
</div> </div>
)} )}
</div> </div>
@ -59,13 +58,7 @@ export function Welcome({
)} )}
</div> </div>
) : ( ) : (
<div className="text-muted-foreground text-sm"> <div> </div>
{t.welcome.description.includes("\n") ? (
<pre className="whitespace-pre">{t.welcome.description}</pre>
) : (
<p>{t.welcome.description}</p>
)}
</div>
)} )}
</div> </div>
); );

View File

@ -24,6 +24,8 @@ export const enUS: Translations = {
delete: "Delete", delete: "Delete",
rename: "Rename", rename: "Rename",
share: "Share", share: "Share",
fullScreen: "fullScreen",
closeFullScreen: "closeFullScreen",
openInNewWindow: "Open in new window", openInNewWindow: "Open in new window",
close: "Close", close: "Close",
more: "More", more: "More",
@ -69,7 +71,10 @@ export const enUS: Translations = {
placeholder: "How can I assist you today?", placeholder: "How can I assist you today?",
createSkillPrompt: createSkillPrompt:
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?", "We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
sendMessagePrice:
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
addAttachments: "Add attachments", addAttachments: "Add attachments",
selectSkill: "Select Skill",
mode: "Mode", mode: "Mode",
flashMode: "Flash", flashMode: "Flash",
flashModeDescription: "Fast and efficient, but may not be accurate", flashModeDescription: "Fast and efficient, but may not be accurate",
@ -82,30 +87,61 @@ export const enUS: Translations = {
ultraMode: "Ultra", ultraMode: "Ultra",
ultraModeDescription: ultraModeDescription:
"Pro mode with subagents to divide work; best for complex multi-step tasks", "Pro mode with subagents to divide work; best for complex multi-step tasks",
reasoningEffort: "Reasoning Effort",
reasoningEffortMinimal: "Minimal",
reasoningEffortMinimalDescription: "Retrieval + Direct Output",
reasoningEffortLow: "Low",
reasoningEffortLowDescription: "Simple Logic Check + Shallow Deduction",
reasoningEffortMedium: "Medium",
reasoningEffortMediumDescription:
"Multi-layer Logic Analysis + Basic Verification",
reasoningEffortHigh: "High",
reasoningEffortHighDescription:
"Full-dimensional Logic Deduction + Multi-path Verification + Backward Check",
searchModels: "Search models...", searchModels: "Search models...",
surpriseMe: "Surprise", surpriseMe: "Surprise",
surpriseMePrompt: "Surprise me", surpriseMePrompt: "Surprise me",
followupLoading: "Generating follow-up questions...",
followupConfirmTitle: "Send suggestion?",
followupConfirmDescription:
"You already have text in the input. Choose how to send it.",
followupConfirmAppend: "Append & send",
followupConfirmReplace: "Replace & send",
suggestions: [ suggestions: [
{ {
suggestion: "Write", suggestion: "Paper Writing",
prompt: "Write a blog post about the latest trends on [topic]",
icon: PenLineIcon,
},
{
suggestion: "Research",
prompt: prompt:
"Conduct a deep dive research on [topic], and summarize the findings.", "Write an academic paper about [topic], including abstract, introduction, body and references.",
icon: PenLineIcon,
skill_id: "paper-writing",
},
{
suggestion: "Report Generation",
prompt:
"Analyze [topic] in depth and generate a well-structured research report.",
icon: MicroscopeIcon, icon: MicroscopeIcon,
skill_id: "report-generation",
}, },
{ {
suggestion: "Collect", suggestion: "Copywriting",
prompt: "Collect data from [source] and create a report.", prompt:
"Create a complete planning proposal and promotional copy for [project/event].",
icon: ShapesIcon, icon: ShapesIcon,
skill_id: "planning-copywriting",
}, },
{ {
suggestion: "Learn", suggestion: "PPT Generation",
prompt: "Learn about [topic] and create a tutorial.", prompt:
"Generate a PPT presentation outline and content about [topic].",
icon: GraduationCapIcon, icon: GraduationCapIcon,
skill_id: "ppt-generation",
},
{
suggestion: "Document Processing",
prompt:
"Process [document] with reading, summarizing, translating or format conversion.",
icon: CompassIcon,
skill_id: "document-processing",
}, },
], ],
suggestionsCreate: [ suggestionsCreate: [
@ -142,6 +178,41 @@ export const enUS: Translations = {
chats: "Chats", chats: "Chats",
recentChats: "Recent chats", recentChats: "Recent chats",
demoChats: "Demo chats", demoChats: "Demo chats",
agents: "Agents",
},
// Agents
agents: {
title: "Agents",
description:
"Create and manage custom agents with specialized prompts and capabilities.",
newAgent: "New Agent",
emptyTitle: "No custom agents yet",
emptyDescription:
"Create your first custom agent with a specialized system prompt.",
chat: "Chat",
delete: "Delete",
deleteConfirm:
"Are you sure you want to delete this agent? This action cannot be undone.",
deleteSuccess: "Agent deleted",
newChat: "New chat",
createPageTitle: "Design your Agent",
createPageSubtitle:
"Describe the agent you want — I'll help you create it through conversation.",
nameStepTitle: "Name your new Agent",
nameStepHint:
"Letters, digits, and hyphens only — stored lowercase (e.g. code-reviewer)",
nameStepPlaceholder: "e.g. code-reviewer",
nameStepContinue: "Continue",
nameStepInvalidError:
"Invalid name — use only letters, digits, and hyphens",
nameStepAlreadyExistsError: "An agent with this name already exists",
nameStepCheckError: "Could not verify name availability — please try again",
nameStepBootstrapMessage:
"The new custom agent name is {name}. Let's bootstrap it's **SOUL**.",
agentCreated: "Agent created!",
startChatting: "Start chatting",
backToGallery: "Back to Gallery",
}, },
// Breadcrumb // Breadcrumb
@ -204,6 +275,11 @@ export const enUS: Translations = {
}, },
// Subtasks // Subtasks
uploads: {
uploading: "Uploading...",
uploadingFiles: "Uploading files, please wait...",
},
subtasks: { subtasks: {
subtask: "Subtask", subtask: "Subtask",
executing: (count: number) => executing: (count: number) =>

View File

@ -15,6 +15,8 @@ export interface Translations {
share: string; share: string;
openInNewWindow: string; openInNewWindow: string;
close: string; close: string;
fullScreen: string;
closeFullScreen: string;
more: string; more: string;
search: string; search: string;
download: string; download: string;
@ -52,9 +54,11 @@ export interface Translations {
// Input Box // Input Box
inputBox: { inputBox: {
sendMessagePrice: string;
placeholder: string; placeholder: string;
createSkillPrompt: string; createSkillPrompt: string;
addAttachments: string; addAttachments: string;
selectSkill: string;
mode: string; mode: string;
flashMode: string; flashMode: string;
flashModeDescription: string; flashModeDescription: string;
@ -64,13 +68,28 @@ export interface Translations {
proModeDescription: string; proModeDescription: string;
ultraMode: string; ultraMode: string;
ultraModeDescription: string; ultraModeDescription: string;
reasoningEffort: string;
reasoningEffortMinimal: string;
reasoningEffortMinimalDescription: string;
reasoningEffortLow: string;
reasoningEffortLowDescription: string;
reasoningEffortMedium: string;
reasoningEffortMediumDescription: string;
reasoningEffortHigh: string;
reasoningEffortHighDescription: string;
searchModels: string; searchModels: string;
surpriseMe: string; surpriseMe: string;
surpriseMePrompt: string; surpriseMePrompt: string;
followupLoading: string;
followupConfirmTitle: string;
followupConfirmDescription: string;
followupConfirmAppend: string;
followupConfirmReplace: string;
suggestions: { suggestions: {
suggestion: string; suggestion: string;
prompt: string; prompt: string;
icon: LucideIcon; icon: LucideIcon;
skill_id?: string;
}[]; }[];
suggestionsCreate: ( suggestionsCreate: (
| { | {
@ -90,6 +109,34 @@ export interface Translations {
newChat: string; newChat: string;
chats: string; chats: string;
demoChats: string; demoChats: string;
agents: string;
};
// Agents
agents: {
title: string;
description: string;
newAgent: string;
emptyTitle: string;
emptyDescription: string;
chat: string;
delete: string;
deleteConfirm: string;
deleteSuccess: string;
newChat: string;
createPageTitle: string;
createPageSubtitle: string;
nameStepTitle: string;
nameStepHint: string;
nameStepPlaceholder: string;
nameStepContinue: string;
nameStepInvalidError: string;
nameStepAlreadyExistsError: string;
nameStepCheckError: string;
nameStepBootstrapMessage: string;
agentCreated: string;
startChatting: string;
backToGallery: string;
}; };
// Breadcrumb // Breadcrumb
@ -150,6 +197,12 @@ export interface Translations {
skillInstallTooltip: string; skillInstallTooltip: string;
}; };
// Uploads
uploads: {
uploading: string;
uploadingFiles: string;
};
// Subtasks // Subtasks
subtasks: { subtasks: {
subtask: string; subtask: string;

View File

@ -26,11 +26,13 @@ export const zhCN: Translations = {
share: "分享", share: "分享",
openInNewWindow: "在新窗口打开", openInNewWindow: "在新窗口打开",
close: "关闭", close: "关闭",
fullScreen: "全屏",
closeFullScreen: "关闭全屏",
more: "更多", more: "更多",
search: "搜索", search: "搜索",
download: "下载", download: "下载",
thinking: "思考", thinking: "思考",
artifacts: "文件", artifacts: "查看结果",
public: "公共", public: "公共",
custom: "自定义", custom: "自定义",
notAvailableInDemoMode: "在演示模式下不可用", notAvailableInDemoMode: "在演示模式下不可用",
@ -47,7 +49,7 @@ export const zhCN: Translations = {
// Welcome // Welcome
welcome: { welcome: {
greeting: "你好,欢迎回来!", greeting: "使用Skill",
description: description:
"欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。", "欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。",
@ -66,10 +68,13 @@ export const zhCN: Translations = {
// Input Box // Input Box
inputBox: { inputBox: {
placeholder: "今天我能为你做些什么?", placeholder: "先输入说明需求选择Skill开始使用吧",
createSkillPrompt: createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。", "我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
sendMessagePrice:
"请注意此功能将消耗token请保证账户余额大于200可学豆。",
addAttachments: "添加附件", addAttachments: "添加附件",
selectSkill: "选择Skill",
mode: "模式", mode: "模式",
flashMode: "闪速", flashMode: "闪速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准", flashModeDescription: "快速且高效的完成任务,但可能不够精准",
@ -80,29 +85,54 @@ export const zhCN: Translations = {
ultraMode: "Ultra", ultraMode: "Ultra",
ultraModeDescription: ultraModeDescription:
"继承自 Pro 模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强", "继承自 Pro 模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
reasoningEffort: "推理深度",
reasoningEffortMinimal: "最低",
reasoningEffortMinimalDescription: "检索 + 直接输出",
reasoningEffortLow: "低",
reasoningEffortLowDescription: "简单逻辑校验 + 浅层推演",
reasoningEffortMedium: "中",
reasoningEffortMediumDescription: "多层逻辑分析 + 基础验证",
reasoningEffortHigh: "高",
reasoningEffortHighDescription: "全维度逻辑推演 + 多路径验证 + 反推校验",
searchModels: "搜索模型...", searchModels: "搜索模型...",
surpriseMe: "小惊喜", surpriseMe: "小惊喜",
surpriseMePrompt: "给我一个小惊喜吧", surpriseMePrompt: "给我一个小惊喜吧",
followupLoading: "正在生成可能的后续问题...",
followupConfirmTitle: "发送建议问题?",
followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
followupConfirmAppend: "追加并发送",
followupConfirmReplace: "替换并发送",
suggestions: [ suggestions: [
{ {
suggestion: "写作", suggestion: "论文写作",
prompt: "撰写一篇关于[主题]的博客文章", prompt:
"撰写一篇关于[主题]的学术论文,包含摘要、引言、正文和参考文献。",
icon: PenLineIcon, icon: PenLineIcon,
skill_id: "1",
}, },
{ {
suggestion: "研究", suggestion: "报告生成",
prompt: "深入浅出的研究一下[主题],并总结发现。", prompt: "深入分析[主题],生成一份结构清晰的调研报告。",
icon: MicroscopeIcon, icon: MicroscopeIcon,
skill_id: "2",
}, },
{ {
suggestion: "收集", suggestion: "策划文案",
prompt: "从[来源]收集数据并创建报告。", prompt: "为[项目/活动]撰写一份完整的策划方案和宣传文案。",
icon: ShapesIcon, icon: ShapesIcon,
skill_id: "3",
}, },
{ {
suggestion: "学习", suggestion: "PPT生成",
prompt: "学习关于[主题]并创建教程。", prompt: "生成一个关于[主题]的PPT演示文稿大纲和内容。",
icon: GraduationCapIcon, icon: GraduationCapIcon,
skill_id: "4",
},
{
suggestion: "文档处理",
prompt: "对[文档]进行阅读、总结、翻译或格式转换等处理。",
icon: CompassIcon,
skill_id: "5",
}, },
], ],
suggestionsCreate: [ suggestionsCreate: [
@ -139,6 +169,36 @@ export const zhCN: Translations = {
chats: "对话", chats: "对话",
recentChats: "最近的对话", recentChats: "最近的对话",
demoChats: "演示对话", demoChats: "演示对话",
agents: "智能体",
},
// Agents
agents: {
title: "智能体",
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
newAgent: "新建智能体",
emptyTitle: "还没有自定义智能体",
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
chat: "对话",
delete: "删除",
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
deleteSuccess: "智能体已删除",
newChat: "新对话",
createPageTitle: "设计你的智能体",
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
nameStepTitle: "给新智能体起个名字",
nameStepHint:
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer",
nameStepPlaceholder: "例如 code-reviewer",
nameStepContinue: "继续",
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
nameStepAlreadyExistsError: "已存在同名智能体",
nameStepCheckError: "无法验证名称可用性,请稍后重试",
nameStepBootstrapMessage:
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
agentCreated: "智能体已创建!",
startChatting: "开始对话",
backToGallery: "返回 Gallery",
}, },
// Breadcrumb // Breadcrumb
@ -199,6 +259,11 @@ export const zhCN: Translations = {
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用", skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
}, },
uploads: {
uploading: "上传中...",
uploadingFiles: "文件上传中,请稍候...",
},
subtasks: { subtasks: {
subtask: "子任务", subtask: "子任务",
executing: (count: number) => executing: (count: number) =>

View File

@ -8,14 +8,12 @@ export async function loadMCPConfig() {
} }
export async function updateMCPConfig(config: MCPConfig) { export async function updateMCPConfig(config: MCPConfig) {
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, {
{ method: "PUT",
method: "PUT", headers: {
headers: { "Content-Type": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(config),
}, },
); body: JSON.stringify(config),
});
return response.json(); return response.json();
} }

View File

@ -18,6 +18,29 @@ import type {
AgentThreadState, AgentThreadState,
} from "./types"; } from "./types";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForThreadStateToBeReadable(
apiClient: ReturnType<typeof getAPIClient>,
threadId: string,
timeoutMs = 3000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const state = await apiClient.threads.getState<AgentThreadState>(threadId);
if ((state.values.messages?.length ?? 0) > 0) {
return;
}
} catch {
// Ignore transient 404 / not-ready errors while the new thread is being persisted.
}
await sleep(100);
}
}
export function useThreadStream({ export function useThreadStream({
threadId, threadId,
isNewThread, isNewThread,
@ -188,6 +211,11 @@ export function useSubmitThread({
}, },
}, },
); );
if (createNewSession && isNewThread && threadId) {
await waitForThreadStateToBeReadable(apiClient, threadId);
}
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.(); afterSubmit?.();
}, },

View File

@ -0,0 +1,77 @@
import { useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
// 消息类型常量
const MESSAGE_TYPES = {
SELECT_SKILL: "selectSkill",
OPEN_SKILL_DIALOG: "openSkillDialog",
} as const;
// Skill 数据类型
interface SkillData {
skill_id: string;
title: string;
}
// Hook 返回类型
interface UseIframeSkillReturn {
selectedSkill: SkillData | null;
sendSelectSkill: (skill_id: string) => void;
openSkillDialog: () => void;
clearSkill: () => void;
}
export function useIframeSkill(): UseIframeSkillReturn {
const searchParams = useSearchParams();
const skillIdFromQuery = searchParams.get("skill_id");
const titleFromQuery = searchParams.get("title");
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
// 1. 监听 query 参数变化
useEffect(() => {
if (skillIdFromQuery && titleFromQuery) {
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
}
}, [skillIdFromQuery, titleFromQuery]);
// 2. 监听宿主页 postMessage
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "selectedSkill") {
const { id, title } = event.data;
setSelectedSkill({ skill_id: String(id), title });
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// 发送选择预定义 skill
const sendSelectSkill = useCallback((skill_id: string) => {
const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id };
console.log("[useIframeSkill] sendSelectSkill:", message);
window.parent.postMessage(message, "*");
}, []);
// 打开 skill 选择对话框
const openSkillDialog = useCallback(() => {
const message = {
type: MESSAGE_TYPES.OPEN_SKILL_DIALOG,
openSkillDialog: true,
};
console.log("[useIframeSkill] openSkillDialog:", message);
window.parent.postMessage(message, "*");
}, []);
// 清除选中并发送 skill_id=0 给主页
const clearSkill = useCallback(() => {
setSelectedSkill(null);
// 发送 skill_id=0 给主页,通知取消选择
const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" };
console.log("[useIframeSkill] clearSkill, sending skill_id=0:", message);
window.parent.postMessage(message, "*");
}, []);
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
}

View File

@ -1,19 +1,21 @@
import * as React from "react" import * as React from "react";
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
} };
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }

View File

@ -0,0 +1,146 @@
import { useSearchParams } from "next/navigation";
import { useEffect, useCallback, useState, useRef } from "react";
import { toast } from "sonner";
import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 宿主页发过来的 selectedSkill 消息结构 */
interface SelectedSkillMessage {
type: "selectedSkill";
id: number | string;
title: string;
}
/** 技能基础数据 */
interface SkillData {
skill_id: string;
title: string;
}
/** 错误信息状态 */
interface SkillError {
title: string;
message: string;
}
interface UseSelectedSkillListenerOptions {
/** 当前会话 thread_id用于调用 bootstrapRemoteSkill */
threadId: string | null;
}
interface UseSelectedSkillListenerReturn {
/** 当前选中的技能数据(用于 UI 展示,如 Badge */
selectedSkill: SkillData | null;
/** 当前错误信息,不为 null 时展示 DevDialog */
skillError: SkillError | null;
/** 清除错误信息(关闭 DevDialog 时调用) */
clearSkillError: () => void;
/** 是否正在加载(处理 skill 中) */
isBootstrapping: boolean;
}
/**
* 宿 postMessage selectedSkill URL skill
* bootstrapRemoteSkill
* - 使 toast
* - skillError DevDialog
*/
export function useSelectedSkillListener({
threadId,
}: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn {
const searchParams = useSearchParams();
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
const [skillError, setSkillError] = useState<SkillError | null>(null);
const [isBootstrapping, setIsBootstrapping] = useState(false);
const isFirstLoadRef = useRef(false);
const skillBootstrappedKeyRef = useRef<string | null>(null);
const performBootstrap = useCallback(
async (id: number | string, title: string) => {
if (!threadId) return;
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const initKey = `${threadId}:${id}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey) {
return;
}
console.log(`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`);
setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
try {
const result = await bootstrapRemoteSkill({
thread_id: threadId,
content_id: Number(id),
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
toast.dismiss("skill-bootstrap");
if (result.success) {
skillBootstrappedKeyRef.current = initKey;
toast.success(`技能「${title}」加载成功`, {
description: result.message || `已创建 ${result.created_files} 个文件`,
duration: 4000,
});
} else {
setSkillError({
title: `技能「${title}」加载失败`,
message: result.message || "未知错误",
});
}
} catch (err) {
toast.dismiss("skill-bootstrap");
const message = err instanceof Error ? err.message : "网络请求失败";
setSkillError({ title: `技能「${title}」加载出错`, message });
} finally {
setIsBootstrapping(false);
}
},
[threadId, searchParams],
);
// 1. URL 初始化集成
useEffect(() => {
if (!threadId || isFirstLoadRef.current) return;
const skillIdFromQuery = searchParams.get("skill_id");
const titleFromQuery = searchParams.get("title");
if (skillIdFromQuery && titleFromQuery) {
isFirstLoadRef.current = true;
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
void performBootstrap(skillIdFromQuery, titleFromQuery);
}
}, [threadId, searchParams, performBootstrap]);
const handleMessage = useCallback(
(event: MessageEvent) => {
const data = event.data as SelectedSkillMessage;
if (data?.type !== "selectedSkill") return;
const { id, title } = data;
console.log("[useSelectedSkillListener] 收到 postMessage selectedSkill:", data);
setSelectedSkill({ skill_id: String(id), title });
void performBootstrap(id, title);
},
[performBootstrap],
);
useEffect(() => {
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [handleMessage]);
const clearSkillError = useCallback(() => setSkillError(null), []);
return { selectedSkill, skillError, clearSkillError, isBootstrapping };
}

View File

@ -72,8 +72,9 @@
@theme { @theme {
--font-sans: --font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, "Microsoft YaHei", "微软雅黑", var(--font-geist-sans), ui-sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1.1s; --animate-fade-in: fade-in 1.1s;
@keyframes fade-in { @keyframes fade-in {
@ -185,6 +186,7 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-tooltip-background: var(--tooltip-background);
--animate-aurora: aurora 8s ease-in-out infinite alternate; --animate-aurora: aurora 8s ease-in-out infinite alternate;
@keyframes aurora { @keyframes aurora {
0% { 0% {
@ -224,19 +226,25 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(0.9855 0.0098 87.47); --background: #f9f8fa;
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0.0098 87.47); /* --foreground: #00000066; */
/* --card: oklch(1 0.0098 87.47); */
--card: #ffffff;
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0.0098 87.47); /* --popover: oklch(1 0.0098 87.47); */
--popover: #ffffff;
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0 0 0); --primary: oklch(0 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.9455 0.0098 87.47); /* --secondary: oklch(0.9455 0.0098 87.47); */
--secondary: #1500331a;
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0.0098 87.47); /* --muted: oklch(0.97 0.0098 87.47); */
--muted: #1500331a;
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.94 0.0098 87.47); /* --accent: oklch(0.94 0.0098 87.47); */
--accent: #1500331a;
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.0098 87.47); --border: oklch(0.922 0.0098 87.47);
@ -255,6 +263,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0.0098 87.47); --sidebar-border: oklch(0.922 0.0098 87.47);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--tooltip-background: #00000066;
} }
.dark { .dark {
@ -289,6 +298,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
--tooltip-background: oklch(0.85 0 0);
font-weight: 300; font-weight: 300;
} }
@ -297,7 +307,7 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply text-foreground;
} }
.container-md { .container-md {
@ -384,9 +394,62 @@
} }
} }
/* Hide scrollbar but keep scroll behavior */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
:root { :root {
--container-width-xs: calc(var(--spacing) * 72); --container-width-xs: calc(var(--spacing) * 72);
--container-width-sm: calc(var(--spacing) * 144); --container-width-sm: calc(var(--spacing) * 144);
--container-width-md: calc(var(--spacing) * 204); --container-width-md: calc(var(--spacing) * 204);
--container-width-lg: calc(var(--spacing) * 256); --container-width-lg: calc(var(--spacing) * 256);
} }
/* ========================================
Streamdown Markdown Styles
使用 data-streamdown 属性选择器统一定义
支持 zoom-scale CSS 变量进行缩放
======================================== */
/* 缩放变量,默认为 1 (100%) */
:root {
--zoom-scale: 1;
}
/* p标签没有标识data-streamdown暂时只能这么写 */
p {
font-size: calc(14px * var(--zoom-scale));
}
/* 列表项 - 14px */
[data-streamdown="list-item"] {
font-size: calc(14px * var(--zoom-scale));
padding-top: calc(4px * var(--zoom-scale));
padding-bottom: calc(4px * var(--zoom-scale));
}
/* 一级标题 - 20px */
[data-streamdown="heading-1"] {
font-size: calc(20px * var(--zoom-scale));
}
/* 二三级标题 - 16px */
[data-streamdown="heading-2"],
[data-streamdown="heading-3"] {
font-size: calc(16px * var(--zoom-scale));
}
/* 二三级标题 - 16px */
[data-streamdown="code-block"] pre {
font-size: calc(16px * var(--zoom-scale));
}
.cm-line {
font-size: calc(14px * var(--zoom-scale));
}

90
memo.md
View File

@ -1,90 +0,0 @@
# 当前改动总结2026-03-09
## TODO备忘AIO sandbox端口分配并发竞态
- 问题sandbox容器启动时端口分配get_free_port + docker run非原子操作存在并发竞态。
- 多个会话并发检测到端口空闲,可能同时尝试分配同一端口,导致后一个容器启动失败(端口已被占用)。
- 建议:
- 增加端口分配锁如文件锁、Redis等保证端口分配与容器启动原子性。
- 或在容器启动失败后自动重试分配新端口。
- 适用于高并发场景,低并发下概率极低。
# 当前改动总结2026-03-09
## 7) 近期补充变更2026-03-13
- langgraph 会话持久化:
- 支持 langgraph API 层会话落盘,重启后历史线程/消息可恢复。
- 通过 .langgraph_api 挂载和主进程 exec 启动,保证 SIGTERM 优雅关闭和持久化。
- skill 扫描范围扩展:
- skill 扫描目录新增 /mnt/user-data/uploads 路径,支持用户上传 skill-yaml、skill 文件自动纳入扫描。
- 兼容 skill-package.yaml、skill.zip 等多种格式,自动生成 skill 目录。
- 外部创建 langgraph 会话:
- 支持通过 API/外部服务创建 langgraph 会话thread_id 可由外部指定。
- 前端/第三方系统可直接初始化会话并绑定 skill_id实现多入口集成。
# 当前改动总结2026-03-09
## 6) Skill YAML 自动导入与远程初始化2026-03-13
- 目标:支持上传 skill-yaml.yaml一键导入为 skill 目录,并支持 skill_id/languageType 参数自动远程初始化。
- 前端:
- 新增 materializeSkillYaml/ bootstrapRemoteSkill API支持 skill-yaml 文件解析与远程内容拉取。
- 在 chats/[thread_id]/page.tsx 页面加载时自动触发 skill 初始化(有 skill_id 参数时),并用 useEffect/Ref 保证只初始化一次。
- 提交时增加空消息 guard避免页面初始化时误触发 submit。
- 上传文件卡片支持 YAML 文件解析materializeSkillYaml但按钮默认注释后续可按需开放。
- 初始化期间禁用输入框UI 显示“正在初始化 Skill 文件...”或失败提示。
- 后端:
- gateway/config.py 增加 skill_content_api_url 配置,支持环境变量覆盖。
- routers/skills.py 新增 /api/skills/materialize-yaml 与 /api/skills/bootstrap-remote 两个 POST 接口,分别支持本地 YAML 解析与远程内容拉取+目录生成。
- skill_yaml_importer.py 增强解析器,支持 package.structure、path/name/children、root sentinel、别名等多种 YAML schema兼容复杂 skill-package.yaml。
- 解析异常时返回详细错误,前端可捕获并提示。
- 流程说明:
1. 用户上传 skill-yaml.yaml可在文件卡片触发“导入为 Skill 目录”APImaterializeSkillYaml前端已注释掉相关代码
2. 页面有 skill_id/languageType 参数时,自动调用 bootstrapRemoteSkill拉取远程 YAML 并生成目录。
3. skill 初始化总在 skill 扫描前完成,且只触发一次,支持新/旧线程。
4. 提交时空消息 guard避免页面加载时误触发 submit。
- 文件:
- frontend/src/app/workspace/chats/[thread_id]/page.tsx
- frontend/src/core/skills/api.ts
- frontend/src/components/workspace/messages/message-list-item.tsx
- frontend/src/core/threads/hooks.ts
- backend/src/gateway/config.py
- backend/src/gateway/routers/skills.py
- backend/src/gateway/skill_yaml_importer.py
- 效果skill-yaml 自动导入、远程 skill 初始化、页面加载触发、解析器兼容多 schema前后端 API 完整闭环。
# 当前改动总结2026-03-09
## 1) AIO sandbox 网络访问修复(已完成)
- 问题:容器内访问 `localhost` 实际指向容器自身,无法访问宿主机上的 sandbox 端口。
- 调整:将 sandbox 访问地址改为 `host.docker.internal`
- 文件:`backend/src/community/aio_sandbox/local_backend.py`
- 影响:`create()` / `discover()` 走宿主机映射端口可达。
## 2) AIO sandbox Docker 权限与连通性修复(已完成)
- 问题:`gateway` / `langgraph` 容器无法直接访问 Docker daemon无法管理 AIO sandbox 容器。
- 调整:为两个服务挂载 Docker socket并设置 `DOCKER_HOST`
- 文件:`docker/docker-compose-dev.yaml`
- 关键配置:
- volume: `/var/run/docker.sock:/var/run/docker.sock:ro`
- env: `DOCKER_HOST=unix:///var/run/docker.sock`
## 3) 会话持久化最终生效方案(已验证)
- 目标:`make docker-stop && make docker-start` 后 `/workspace/chats` 保留历史线程。
- 最终生效改动(都在 `langgraph` 服务):
1. 启动命令改为 `exec uv run langgraph dev ... --no-reload ...`
2. 挂载持久化目录:`../backend/.langgraph_api:/app/backend/.langgraph_api`
- 文件:`docker/docker-compose-dev.yaml`
### 原理说明
- `.langgraph_api` 挂载:把 inmem runtime 的落盘文件映射到宿主机,容器重建后仍保留。
- `exec`:让 LangGraph 主进程直接接收 `SIGTERM`,触发优雅关闭与 `PersistentDict` 写盘。
- `--no-reload`:避免热重载多进程导致的停机时序问题,保证落盘稳定。
## 5) Docker下目录权限修复已完成
- 目标:确保 sandbox 容器/Pod 挂载的 `/mnt/user-data` 及其子目录可被非 root 用户正常读写,避免上传/写入失败。
- 文件:
- `backend/src/community/aio_sandbox/aio_sandbox_provider.py`
- `backend/src/community/aio_sandbox/local_backend.py`
- `docker/provisioner/app.py`
- 关键措施:
1. 启动 sandbox 时自动创建 thread 挂载目录并 `chmod 777`,解析为 host 路径,保证权限生效。
2. 容器启动后用 `docker exec` 执行 `mkdir -p``chmod 777`,多次重试,确保 `/mnt/user-data/uploads/workspace/outputs` 可写。
3. K8s Pod spec 新增 init container以 root 权限初始化 `/mnt/user-data``chmod -R 777`,安全上下文限制特权。
- 效果sandbox 挂载目录无论本地还是 K8s均可被非 root 用户正常写入,上传/输出/工作区权限问题彻底解决。