feat(03): align workspace visual layer with legacy baseline

This commit is contained in:
肖应宇 2026-04-07 14:34:22 +08:00
parent 981bb8f005
commit 7012693802
62 changed files with 2859 additions and 3000 deletions

View File

@ -1,16 +1,8 @@
"use client";
import {
ArrowLeftIcon,
BotIcon,
CheckCircleIcon,
InfoIcon,
MoreHorizontalIcon,
SaveIcon,
} from "lucide-react";
import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { useCallback, useMemo, useState } from "react";
import {
PromptInput,
@ -18,14 +10,7 @@ import {
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
@ -35,50 +20,26 @@ import { checkAgentName, getAgent } from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
type Step = "name" | "chat";
type SetupAgentStatus = "idle" | "requested" | "completed";
const NAME_RE = /^[A-Za-z0-9-]+$/;
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function getAgentWithRetry(agentName: string) {
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
if (delay > 0) {
await wait(delay);
}
try {
return await getAgent(agentName);
} catch {
// Retry until the write settles or the attempts are exhausted.
}
}
return null;
}
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
// ── Step 1: name form ──────────────────────────────────────────────────────
const [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false);
const [agentName, setAgentName] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
const [showSaveHint, setShowSaveHint] = useState(false);
const [setupAgentStatus, setSetupAgentStatus] =
useState<SetupAgentStatus>("idle");
// ── Step 2: chat ───────────────────────────────────────────────────────────
// Stable thread ID — all turns belong to the same thread
const threadId = useMemo(() => uuid(), []);
const [thread, sendMessage] = useThreadStream({
@ -87,35 +48,17 @@ export default function NewAgentPage() {
mode: "flash",
is_bootstrap: true,
},
onFinish() {
if (!agent && setupAgentStatus === "requested") {
setSetupAgentStatus("idle");
}
},
onToolEnd({ name }) {
if (name !== "setup_agent" || !agentName) return;
setSetupAgentStatus("completed");
void getAgentWithRetry(agentName).then((fetched) => {
if (fetched) {
setAgent(fetched);
return;
}
toast.error(t.agents.agentCreatedPendingRefresh);
});
getAgent(agentName)
.then((fetched) => setAgent(fetched))
.catch(() => {
// agent write may not be flushed yet — ignore silently
});
},
});
useEffect(() => {
if (typeof window === "undefined" || step !== "chat") {
return;
}
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") {
return;
}
setShowSaveHint(true);
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1");
}, [step]);
// ── Handlers ───────────────────────────────────────────────────────────────
const handleConfirmName = useCallback(async () => {
const trimmed = nameInput.trim();
@ -124,7 +67,6 @@ export default function NewAgentPage() {
setNameError(t.agents.nameStepInvalidError);
return;
}
setNameError("");
setIsCheckingName(true);
try {
@ -133,17 +75,12 @@ export default function NewAgentPage() {
setNameError(t.agents.nameStepAlreadyExistsError);
return;
}
} catch (err) {
if (err instanceof TypeError && err.message === "Failed to fetch") {
setNameError(t.agents.nameStepNetworkError);
} else {
setNameError(t.agents.nameStepCheckError);
}
} catch {
setNameError(t.agents.nameStepCheckError);
return;
} finally {
setIsCheckingName(false);
}
setAgentName(trimmed);
setStep("chat");
await sendMessage(threadId, {
@ -153,16 +90,15 @@ export default function NewAgentPage() {
}, [
nameInput,
sendMessage,
t.agents.nameStepAlreadyExistsError,
t.agents.nameStepNetworkError,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepCheckError,
t.agents.nameStepInvalidError,
threadId,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepInvalidError,
t.agents.nameStepAlreadyExistsError,
t.agents.nameStepCheckError,
]);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
if (e.key === "Enter") {
e.preventDefault();
void handleConfirmName();
}
@ -178,82 +114,26 @@ export default function NewAgentPage() {
{ agent_name: agentName },
);
},
[agentName, sendMessage, thread.isLoading, threadId],
[thread.isLoading, sendMessage, threadId, agentName],
);
const handleSaveAgent = useCallback(async () => {
if (
!agentName ||
agent ||
thread.isLoading ||
setupAgentStatus !== "idle"
) {
return;
}
setSetupAgentStatus("requested");
setShowSaveHint(false);
try {
await sendMessage(
threadId,
{ text: t.agents.saveCommandMessage, files: [] },
{ agent_name: agentName },
{ additionalKwargs: { hide_from_ui: true } },
);
toast.success(t.agents.saveRequested);
} catch (error) {
setSetupAgentStatus("idle");
toast.error(error instanceof Error ? error.message : String(error));
}
}, [
agent,
agentName,
sendMessage,
setupAgentStatus,
t.agents.saveCommandMessage,
t.agents.saveRequested,
thread.isLoading,
threadId,
]);
// ── Shared header ──────────────────────────────────────────────────────────
const header = (
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
</div>
{step === "chat" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => void handleSaveAgent()}
disabled={
!!agent || thread.isLoading || setupAgentStatus !== "idle"
}
>
<SaveIcon className="h-4 w-4" />
{setupAgentStatus === "requested"
? t.agents.saving
: t.agents.save}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<header className="flex shrink-0 items-center gap-3 border-b px-4 py-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
</header>
);
// ── Step 1: name form ──────────────────────────────────────────────────────
if (step === "name") {
return (
<div className="flex size-full flex-col">
@ -286,9 +166,9 @@ export default function NewAgentPage() {
onKeyDown={handleNameKeyDown}
className={cn(nameError && "border-destructive")}
/>
{nameError ? (
{nameError && (
<p className="text-destructive text-sm">{nameError}</p>
) : null}
)}
<Button
className="w-full"
onClick={() => void handleConfirmName()}
@ -303,6 +183,8 @@ export default function NewAgentPage() {
);
}
// ── Step 2: chat ───────────────────────────────────────────────────────────
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
@ -310,28 +192,20 @@ export default function NewAgentPage() {
{header}
<main className="flex min-h-0 flex-1 flex-col">
{showSaveHint ? (
<div className="px-4 pt-4">
<div className="mx-auto w-full max-w-(--container-width-md)">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>{t.agents.saveHint}</AlertDescription>
</Alert>
</div>
</div>
) : null}
{/* ── Message area ── */}
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
className="size-full pt-10"
threadId={threadId}
thread={thread}
/>
</div>
{/* ── Bottom action area ── */}
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
<div className="w-full max-w-(--container-width-md)">
{agent ? (
// ✅ Success card
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
<CheckCircleIcon className="text-primary h-10 w-10" />
<p className="font-semibold">{t.agents.agentCreated}</p>
@ -354,6 +228,7 @@ export default function NewAgentPage() {
</div>
</div>
) : (
// 📝 Normal input
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>

View File

@ -1,10 +1,17 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Toaster } from "sonner";
import { useSearchParams } from "next/navigation";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
import { CommandPalette } from "@/components/workspace/command-palette";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { getLocalSettings, useLocalSettings } from "@/core/settings";
@ -16,6 +23,14 @@ export default function WorkspaceLayout({
}: Readonly<{ children: React.ReactNode }>) {
const [settings, setSettings] = useLocalSettings();
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
const [showWorkspaceSidebar, setShowWorkspaceSidebar] = useState(false);
const pressedKeysRef = useRef<Set<string>>(new Set());
const comboTriggeredRef = useRef(false);
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);
@ -23,6 +38,69 @@ export default function WorkspaceLayout({
useEffect(() => {
setOpen(!settings.layout.sidebar_collapsed);
}, [settings.layout.sidebar_collapsed]);
useEffect(() => {
const resetComboTrigger = () => {
comboTriggeredRef.current = false;
};
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
) {
return;
}
pressedKeysRef.current.add(event.key.toLowerCase());
const hasCtrlOrMeta = event.ctrlKey || event.metaKey;
const hasShift = event.shiftKey;
const hasL = pressedKeysRef.current.has("l");
const hasD = pressedKeysRef.current.has("d");
if (
hasCtrlOrMeta &&
hasShift &&
hasL &&
hasD &&
!comboTriggeredRef.current
) {
event.preventDefault();
comboTriggeredRef.current = true;
setShowWorkspaceSidebar((prev) => !prev);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
pressedKeysRef.current.delete(event.key.toLowerCase());
if (
!pressedKeysRef.current.has("l") ||
!pressedKeysRef.current.has("d") ||
(!event.ctrlKey && !event.metaKey) ||
!event.shiftKey
) {
resetComboTrigger();
}
};
const handleBlur = () => {
pressedKeysRef.current.clear();
resetComboTrigger();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, []);
const handleOpenChange = useCallback(
(open: boolean) => {
setOpen(open);
@ -37,11 +115,40 @@ export default function WorkspaceLayout({
open={open}
onOpenChange={handleOpenChange}
>
<WorkspaceSidebar />
{!isSkillMode && showWorkspaceSidebar && (
<WorkspaceSidebar className="" />
)}
<SidebarInset className="min-w-0">{children}</SidebarInset>
</SidebarProvider>
<CommandPalette />
<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>
);
}

View File

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

View File

@ -84,7 +84,7 @@ export const ConversationScrollButton = ({
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
"absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full",
className,
)}
onClick={handleScrollToBottom}

View File

@ -28,7 +28,9 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
from === "user"
? "is-user ml-auto justify-end"
: "is-assistant bg-white p-[20px]",
className,
)}
{...props}

View File

@ -253,7 +253,7 @@ export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
<a
className="flex items-center gap-2"
href={providers.chatgpt.createUrl(query)}
rel="noopener noreferrer"
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.chatgpt.icon}</span>
@ -273,7 +273,7 @@ export const OpenInClaude = (props: OpenInClaudeProps) => {
<a
className="flex items-center gap-2"
href={providers.claude.createUrl(query)}
rel="noopener noreferrer"
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.claude.icon}</span>
@ -293,7 +293,7 @@ export const OpenInT3 = (props: OpenInT3Props) => {
<a
className="flex items-center gap-2"
href={providers.t3.createUrl(query)}
rel="noopener noreferrer"
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.t3.icon}</span>
@ -313,7 +313,7 @@ export const OpenInScira = (props: OpenInSciraProps) => {
<a
className="flex items-center gap-2"
href={providers.scira.createUrl(query)}
rel="noopener noreferrer"
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.scira.icon}</span>
@ -333,7 +333,7 @@ export const OpenInv0 = (props: OpenInv0Props) => {
<a
className="flex items-center gap-2"
href={providers.v0.createUrl(query)}
rel="noopener noreferrer"
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.v0.icon}</span>
@ -353,7 +353,7 @@ export const OpenInCursor = (props: OpenInCursorProps) => {
<a
className="flex items-center gap-2"
href={providers.cursor.createUrl(query)}
rel="noopener noreferrer"
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.cursor.icon}</span>

View File

@ -34,11 +34,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { PromptInputFilePart } from "@/core/uploads";
import { splitUnsupportedUploadFiles } from "@/core/uploads";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai";
import { Tooltip } from "../workspace/tooltip";
import type { ChatStatus, FileUIPart } from "ai";
import {
ArrowUpIcon,
ImageIcon,
@ -73,14 +71,14 @@ import {
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { useI18n } from "@/core/i18n/hooks";
// ============================================================================
// Provider Context & Types
// ============================================================================
export type AttachmentsContext = {
files: (PromptInputFilePart & { id: string })[];
files: (FileUIPart & { id: string })[];
add: (files: File[] | FileList) => void;
remove: (id: string) => void;
clear: () => void;
@ -110,9 +108,6 @@ const PromptInputController = createContext<PromptInputControllerProps | null>(
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null,
);
const PromptInputValidationContext = createContext<
((files: File[] | FileList) => File[]) | null
>(null);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
@ -140,7 +135,6 @@ export const useProviderAttachments = () => {
const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext);
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
@ -160,7 +154,7 @@ export function PromptInputProvider({
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState<
(PromptInputFilePart & { id: string })[]
(FileUIPart & { id: string })[]
>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const openRef = useRef<() => void>(() => {});
@ -179,7 +173,6 @@ export function PromptInputProvider({
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
})),
),
);
@ -287,7 +280,7 @@ export const usePromptInputAttachments = () => {
};
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: PromptInputFilePart & { id: string };
data: FileUIPart & { id: string };
className?: string;
};
@ -297,6 +290,7 @@ export function PromptInputAttachment({
...props
}: PromptInputAttachmentProps) {
const attachments = usePromptInputAttachments();
const { t } = useI18n();
const filename = data.filename || "";
@ -304,81 +298,112 @@ export function PromptInputAttachment({
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
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 (
<PromptInputHoverCard>
<HoverCardTrigger asChild>
<div
className={cn(
"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,
)}
key={data.id}
{...props}
>
<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">
{isImage ? (
<img
alt={filename || "attachment"}
className="size-5 object-cover"
height={20}
src={data.url}
width={20}
/>
) : (
<div className="text-muted-foreground flex size-5 items-center justify-center">
<PaperclipIcon className="size-3" />
</div>
)}
</div>
<Button
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"
<div
className={cn(
"group relative flex size-16 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-lg transition-all select-none",
isImage ? "p-0" : "bg-gray-100 dark:bg-gray-700",
className,
)}
key={data.id}
{...props}
>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
src={data.url}
/>
{/* 悬浮遮罩层 */}
<div
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)" }}
>
{/* 眼睛图标 - 居中 */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
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={t.common.removeAttachment}
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) => {
e.stopPropagation();
attachments.remove(data.id);
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
<svg
xmlns="http://www.w3.org/2000/svg"
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>
<span className="flex-1 truncate">{attachmentLabel}</span>
</div>
</HoverCardTrigger>
<PromptInputHoverCardContent className="w-auto p-2">
<div className="w-auto space-y-3">
{isImage && (
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
<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 className="flex flex-col items-center justify-center gap-1 px-1">
<PaperclipIcon className="size-6 text-gray-400" />
<span className="max-w-full truncate text-center text-[10px] text-gray-500">
{truncateFilename(filename)}
</span>
</div>
</div>
</PromptInputHoverCardContent>
</PromptInputHoverCard>
{/* 关闭按钮 - 右上角 */}
<button
aria-label={t.common.removeAttachment}
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-white/90 opacity-0 transition-opacity group-hover:opacity-100 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>
);
}
@ -386,7 +411,7 @@ export type PromptInputAttachmentsProps = Omit<
HTMLAttributes<HTMLDivElement>,
"children"
> & {
children: (attachment: PromptInputFilePart & { id: string }) => ReactNode;
children: (attachment: FileUIPart & { id: string }) => ReactNode;
};
export function PromptInputAttachments({
@ -402,13 +427,14 @@ export function PromptInputAttachments({
return (
<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}
>
{attachments.files.map((file) => (
<Fragment key={file.id}>
<div className="max-w-60">{children(file)}</div>
</Fragment>
<Fragment key={file.id}>{children(file)}</Fragment>
))}
</div>
);
@ -441,7 +467,7 @@ export const PromptInputActionAddAttachments = ({
export type PromptInputMessage = {
text: string;
files: PromptInputFilePart[];
files: FileUIPart[];
};
export type PromptInputProps = Omit<
@ -459,17 +485,20 @@ export type PromptInputProps = Omit<
maxFiles?: number;
maxFileSize?: number; // bytes
onError?: (err: {
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
code: "max_files" | "max_file_size" | "accept";
message: string;
}) => void;
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>,
) => void | Promise<void>;
// className for InputGroup (passes through to inner InputGroup component)
inputGroupClassName?: string;
};
export const PromptInput = ({
className,
inputGroupClassName,
accept,
disabled,
multiple,
@ -491,9 +520,7 @@ export const PromptInput = ({
const formRef = useRef<HTMLFormElement | null>(null);
// ----- Local attachments (only used when no provider)
const [items, setItems] = useState<(PromptInputFilePart & { id: string })[]>(
[],
);
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
const files = usingProvider ? controller.attachments.files : items;
// Keep a ref to files for cleanup on unmount (avoids stale closure)
@ -561,7 +588,7 @@ export const PromptInput = ({
message: "Too many files. Some were not added.",
});
}
const next: (PromptInputFilePart & { id: string })[] = [];
const next: (FileUIPart & { id: string })[] = [];
for (const file of capped) {
next.push({
id: nanoid(),
@ -569,7 +596,6 @@ export const PromptInput = ({
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
file,
});
}
return prev.concat(next);
@ -610,23 +636,6 @@ export const PromptInput = ({
? controller.attachments.openFileDialog
: openFileDialogLocal;
const sanitizeIncomingFiles = useCallback(
(fileList: File[] | FileList) => {
const { accepted, message } = splitUnsupportedUploadFiles(fileList);
if (message) {
onError?.({
code: "unsupported_package",
message,
});
if (!onError) {
toast.error(message);
}
}
return accepted;
},
[onError],
);
// Let provider know about our hidden file input so external menus can call openFileDialog()
useEffect(() => {
if (!usingProvider) return;
@ -657,10 +666,7 @@ export const PromptInput = ({
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
if (accepted.length > 0) {
add(accepted);
}
add(e.dataTransfer.files);
}
};
form.addEventListener("dragover", onDragOver);
@ -669,7 +675,7 @@ export const PromptInput = ({
form.removeEventListener("dragover", onDragOver);
form.removeEventListener("drop", onDrop);
};
}, [add, globalDrop, sanitizeIncomingFiles]);
}, [add, globalDrop]);
useEffect(() => {
if (!globalDrop) return;
@ -684,10 +690,7 @@ export const PromptInput = ({
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
if (accepted.length > 0) {
add(accepted);
}
add(e.dataTransfer.files);
}
};
document.addEventListener("dragover", onDragOver);
@ -696,7 +699,7 @@ export const PromptInput = ({
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop);
};
}, [add, globalDrop, sanitizeIncomingFiles]);
}, [add, globalDrop]);
useEffect(
() => () => {
@ -712,10 +715,7 @@ export const PromptInput = ({
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
if (event.currentTarget.files) {
const accepted = sanitizeIncomingFiles(event.currentTarget.files);
if (accepted.length > 0) {
add(accepted);
}
add(event.currentTarget.files);
}
// Reset input value to allow selecting files that were previously removed
event.currentTarget.value = "";
@ -752,6 +752,9 @@ export const PromptInput = ({
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
if (disabled) {
return;
}
const form = event.currentTarget;
const text = usingProvider
@ -770,10 +773,6 @@ export const PromptInput = ({
// Convert blob URLs to data URLs asynchronously
Promise.all(
files.map(async ({ id, ...item }) => {
if (item.file instanceof File) {
// Downstream upload prep reads the preserved File directly.
return item;
}
if (item.url && item.url.startsWith("blob:")) {
const dataUrl = await convertBlobUrlToDataUrl(item.url);
// If conversion failed, keep the original blob URL
@ -785,7 +784,7 @@ export const PromptInput = ({
return item;
}),
)
.then((convertedFiles: PromptInputFilePart[]) => {
.then((convertedFiles: FileUIPart[]) => {
try {
const result = onSubmit({ text, files: convertedFiles }, event);
@ -819,7 +818,7 @@ export const PromptInput = ({
// Render with or without local provider
const inner = (
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
<>
<input
accept={accept}
aria-label="Upload files"
@ -836,9 +835,9 @@ export const PromptInput = ({
ref={formRef}
{...props}
>
<InputGroup>{children}</InputGroup>
<InputGroup className={inputGroupClassName}>{children}</InputGroup>
</form>
</PromptInputValidationContext.Provider>
</>
);
return usingProvider ? (
@ -871,12 +870,11 @@ export const PromptInputTextarea = ({
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
if (isIMEComposing(e, isComposing)) {
if (isComposing || e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
@ -930,12 +928,7 @@ export const PromptInputTextarea = ({
if (files.length > 0) {
event.preventDefault();
const accepted = sanitizeIncomingFiles
? sanitizeIncomingFiles(files)
: files;
if (accepted.length > 0) {
attachments.add(accepted);
}
attachments.add(files);
}
};
@ -1075,32 +1068,65 @@ export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
export const PromptInputSubmit = ({
className,
variant = "default",
size = "icon-sm",
size = "sm",
status,
disabled,
children,
...props
}: 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 text: string = "发送";
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
text = "生成中...";
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
text = "停止";
} else if (status === "error") {
// 没有报错状态先用error状态代替
Icon = <XIcon className="size-4" />;
// MARK: 这里后端没有返回错误信息,先写死一个文本
text = "发送";
}
return (
<InputGroupButton
aria-label="Submit"
className={cn(className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</InputGroupButton>
<Tooltip content={t.inputBox.sendMessagePrice}>
<InputGroupButton
aria-label="Submit"
// 被button{bgc:#fff}覆盖了,只能加"!"
className={cn(
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
isDisabled
? "cursor-not-allowed !bg-gray-200 text-gray-400":
"!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
className,
)}
size={size}
type="submit"
variant={variant}
disabled={isDisabled}
{...props}
>
{/* {children ?? Icon} */}
{text}
</InputGroupButton>
</Tooltip>
);
};
@ -1176,8 +1202,6 @@ export const PromptInputSpeechButton = ({
null,
);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const callbacksRef = useRef({ textareaRef, onTranscriptionChange });
callbacksRef.current = { textareaRef, onTranscriptionChange };
useEffect(() => {
if (
@ -1210,19 +1234,15 @@ export const PromptInputSpeechButton = ({
}
}
const currentTextareaRef = callbacksRef.current.textareaRef;
const currentOnTranscriptionChange =
callbacksRef.current.onTranscriptionChange;
if (finalTranscript && currentTextareaRef?.current) {
const textarea = currentTextareaRef.current;
if (finalTranscript && textareaRef?.current) {
const textarea = textareaRef.current;
const currentValue = textarea.value;
const newValue =
currentValue + (currentValue ? " " : "") + finalTranscript;
textarea.value = newValue;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
currentOnTranscriptionChange?.(newValue);
onTranscriptionChange?.(newValue);
}
};
@ -1240,7 +1260,7 @@ export const PromptInputSpeechButton = ({
recognitionRef.current.stop();
}
};
}, []);
}, [textareaRef, onTranscriptionChange]);
const toggleListening = useCallback(() => {
if (!recognition) {

View File

@ -63,7 +63,7 @@ export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noopener noreferrer"
rel="noreferrer"
target="_blank"
{...props}
>

View File

@ -3,6 +3,7 @@
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Icon } from "@radix-ui/react-select";
import type { LucideIcon } from "lucide-react";
import { Children, type ComponentProps } from "react";
@ -60,16 +61,17 @@ export const Suggestion = ({
return (
<Button
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",
"border-none bg-[#F9F8FA] text-[#666666]",
"hover:bg-[#EAE9EB] hover:text-[#150033]",
className,
)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon && <Icon className="size-4" />}
{/* {Icon && <Icon className="size-4" />} */}
{children || suggestion}
</Button>
);

View File

@ -1,54 +1,17 @@
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import type { Locale } from "@/core/i18n/locale";
import { getI18n } from "@/core/i18n/server";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export type HeaderProps = {
className?: string;
homeURL?: string;
locale?: Locale;
};
export async function Header({ className, homeURL, locale }: HeaderProps) {
const isExternalHome = !homeURL;
const { locale: resolvedLocale, t } = await getI18n(locale);
const lang = resolvedLocale.substring(0, 2);
export function Header() {
return (
<header
className={cn(
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs",
className,
)}
>
<div className="flex items-center gap-6">
<a
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
target={isExternalHome ? "_blank" : "_self"}
rel={isExternalHome ? "noopener noreferrer" : undefined}
>
<header className="container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs">
<div className="flex items-center gap-2">
<a href="https://github.com/bytedance/deer-flow" target="_blank">
<h1 className="font-serif text-xl">DeerFlow</h1>
</a>
</div>
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
<Link
href={`/${lang}/docs`}
className="text-secondary-foreground hover:text-foreground transition-colors"
>
{t.home.docs}
</Link>
<a
href={`/${lang}/blog`}
target="_self"
className="text-secondary-foreground hover:text-foreground transition-colors"
>
{t.home.blog}
</a>
</nav>
<div className="relative">
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
@ -63,11 +26,7 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
asChild
className="group relative z-10"
>
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<a href="https://github.com/bytedance/deer-flow" target="_blank">
<GitHubLogoIcon className="size-4" />
Star on GitHub
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&

View File

@ -57,7 +57,6 @@ export function CaseStudySection({ className }: { className?: string }) {
key={caseStudy.title}
href={pathOfThread(caseStudy.threadId) + "?mock=true"}
target="_blank"
rel="noopener noreferrer"
>
<Card className="group/card relative h-64 overflow-hidden">
<div

View File

@ -20,11 +20,7 @@ export function CommunitySection() {
>
<div className="flex justify-center">
<Button className="text-xl" size="lg" asChild>
<Link
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<Link href="https://github.com/bytedance/deer-flow" target="_blank">
<GitHubLogoIcon />
Contribute Now
</Link>

View File

@ -52,8 +52,8 @@ function Button({
return (
<Comp
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
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-xl py-6",
className,
)}
{...props}

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

@ -21,11 +21,13 @@ function DropdownMenuPortal({
}
function DropdownMenuTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
className={cn(className)}
{...props}
/>
);
@ -42,7 +44,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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,
)}
{...props}
@ -128,7 +130,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
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 overflow-hidden rounded-sm py-1.5 pr-2 pl-8 text-sm whitespace-nowrap 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,
)}
{...props}
@ -230,7 +232,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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,
)}
{...props}

View File

@ -0,0 +1,108 @@
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn, truncateMiddle } from "@/lib/utils";
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;
}
function ChevronDownIcon() {
return (
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.75 0.75L4.75 4.75L8.75 0.75"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronUpIcon() {
return (
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.75 4.75L4.75 0.75L8.75 4.75"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DropdownSelector<T extends string>({
value,
options,
onChange,
triggerClassName,
contentClassName,
}: DropdownSelectorProps<T>) {
const selectedOption = options.find((opt) => opt.value === value);
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger
className={
triggerClassName ??
"flex w-full justify-center overflow-hidden border-none bg-transparent text-ellipsis whitespace-nowrap shadow-none select-none focus:outline-none"
}
>
<span className="flex w-full items-center justify-center gap-1">
{truncateMiddle(selectedOption?.label ?? value, 30)}
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn(contentClassName, "max-w-80 p-[20px]")}
>
<DropdownMenuRadioGroup
value={value}
onValueChange={(v) => onChange(v as T)}
>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
title={option.label}
>
{truncateMiddle(option.label)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -14,14 +14,14 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
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 border-input/50 dark:bg-background/80 relative flex w-full items-center overflow-hidden rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-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-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.
"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]",
@ -152,7 +152,7 @@ function InputGroupTextarea({
<Textarea
data-slot="input-group-control"
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,
)}
{...props}

View File

@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
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",
className,
)}

View File

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

View File

@ -69,7 +69,7 @@ function ToggleGroupItem({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 cursor-pointer px-3 focus:z-10 focus-visible:z-10",
"h-full w-auto min-w-0 shrink-0 cursor-pointer px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className,
)}

View File

@ -18,7 +18,7 @@ import {
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { cn, truncateMiddle } from "@/lib/utils";
import { useArtifacts } from "./context";
@ -29,7 +29,7 @@ export function ArtifactFileList({
}: {
className?: string;
files: string[];
threadId: string;
threadId?: string;
}) {
const { t } = useI18n();
const { select: selectArtifact, setOpen } = useArtifacts();
@ -48,6 +48,7 @@ export function ArtifactFileList({
e.stopPropagation();
e.preventDefault();
if (!threadId) return;
if (installingFile) return;
setInstallingFile(filepath);
@ -72,20 +73,29 @@ export function ArtifactFileList({
);
return (
<ul className={cn("flex w-full flex-col gap-4", className)}>
<ul
className={cn("flex w-full flex-col gap-4", className)}
data-testid="artifact-file-list"
>
{files.map((file) => (
<Card
key={file}
className="relative cursor-pointer p-3"
data-testid="artifact-file-card"
onClick={() => handleClick(file)}
>
<CardHeader className="pr-2 pl-1">
<CardTitle className="relative pl-8">
<div>{getFileName(file)}</div>
<div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6")}
<CardTitle className="relative overflow-hidden pl-8">
<div
className="text-sm font-normal text-ellipsis whitespace-nowrap"
title={getFileName(file)}
>
{truncateMiddle(getFileName(file), 50)}
</div>
</CardTitle>
<div className="absolute top-5 left-3">
{getFileIcon(file, "size-6 stroke-[1.5px] stroke-[#333333]")}
</div>
<CardDescription className="pl-8 text-xs">
{getFileExtensionDisplayName(file)} file
</CardDescription>
@ -93,7 +103,7 @@ export function ArtifactFileList({
{file.endsWith(".skill") && (
<Button
variant="ghost"
disabled={installingFile === file}
disabled={!threadId || installingFile === file}
onClick={(e) => handleInstallSkill(e, file)}
>
{installingFile === file ? (
@ -104,21 +114,27 @@ export function ArtifactFileList({
{t.common.install}
</Button>
)}
<a
href={urlOfArtifact({
filepath: file,
threadId: threadId,
download: true,
})}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost">
{threadId ? (
<a
href={urlOfArtifact({
filepath: file,
threadId,
download: true,
})}
target="_blank"
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost">
<DownloadIcon className="size-4" />
{t.common.download}
</Button>
</a>
) : (
<Button variant="ghost" disabled>
<DownloadIcon className="size-4" />
{t.common.download}
</Button>
</a>
)}
</CardAction>
</CardHeader>
</Card>

View File

@ -21,6 +21,9 @@ export interface ArtifactsContextType {
open: boolean;
autoOpen: boolean;
setOpen: (open: boolean) => void;
fullscreen: boolean;
setFullscreen: (fullscreen: boolean) => void;
}
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
@ -39,6 +42,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [autoOpen, setAutoOpen] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
const { setOpen: setSidebarOpen } = useSidebar();
const select = useCallback(
@ -78,6 +82,9 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifact,
select,
deselect,
fullscreen,
setFullscreen,
};
return (

View File

@ -23,7 +23,10 @@ import { useThread } from "../messages/context";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string | undefined;
}> = ({
children,
threadId,
}) => {
@ -132,7 +135,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
// className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>

View File

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

View File

@ -6,7 +6,7 @@ import {
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
CommandDialog,
@ -35,7 +35,6 @@ export function CommandPalette() {
const [open, setOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
router.push("/workspace/chats/new");
@ -64,9 +63,8 @@ export function CommandPalette() {
useGlobalShortcuts(shortcuts);
useEffect(() => {
setIsMac(navigator.userAgent.includes("Mac"));
}, []);
const isMac =
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
const metaKey = isMac ? "⌘" : "Ctrl+";
const shiftKey = isMac ? "⇧" : "Shift+";

View File

@ -0,0 +1,68 @@
"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;
}
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,374 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Button } from "@/components/ui/button";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { copyToClipboard } from "@/lib/utils";
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 [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
const [dragging, setDragging] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const panelSizeRef = useRef({ width: 0, height: 0 });
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");
}
function handleTestClipboardCopy() {
const testText = "测试复制内容 - " + new Date().toISOString();
void copyToClipboard(testText);
addLog(`copyToClipboard → "${testText.slice(0, 30)}..."`);
}
function handleSendXClawUsed(used: boolean) {
sendToParent({
type: POST_MESSAGE_TYPES.XCLAW_USED,
XClawUsed: used,
});
addLog(`postMessage → XClawUsed (${used})`);
}
function handlePointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
panelSizeRef.current = { width: rect.width, height: rect.height };
dragOffsetRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setPosition({ x: rect.left, y: rect.top });
setDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
}
useEffect(() => {
if (!dragging) return;
const handleMove = (event: PointerEvent) => {
const { width, height } = panelSizeRef.current;
const nextX = event.clientX - dragOffsetRef.current.x;
const nextY = event.clientY - dragOffsetRef.current.y;
const clampedX = Math.min(
Math.max(8, nextX),
Math.max(8, window.innerWidth - width - 8),
);
const clampedY = Math.min(
Math.max(8, nextY),
Math.max(8, window.innerHeight - height - 8),
);
setPosition({ x: clampedX, y: clampedY });
};
const handleUp = () => {
setDragging(false);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
return () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
};
}, [dragging]);
// 检测是否在 iframe 中
const isInIframe =
typeof window !== "undefined" && window.self !== window.top;
if (!open) {
return (
<button
className={cn(
"fixed z-[9999] rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white shadow-lg hover:bg-violet-600",
position ? "top-0 left-0" : "bottom-24 left-3",
)}
style={position ? { left: position.x, top: position.y } : undefined}
onClick={() => setOpen(true)}
>
🧪
</button>
);
}
return (
<div
ref={panelRef}
className={cn(
"fixed z-[9999] w-72 rounded-xl border border-violet-200 bg-white/95 shadow-2xl backdrop-blur-sm",
position ? "top-0 left-0" : "bottom-24 left-3",
)}
style={position ? { left: position.x, top: position.y } : undefined}
>
{/* 标题栏 */}
<div
className={cn(
"flex cursor-grab items-center justify-between rounded-t-xl bg-violet-500 px-3 py-2 select-none",
dragging && "cursor-grabbing",
)}
onPointerDown={handlePointerDown}
>
<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>
{/* 场景 4剪贴板复制iframe 通信) */}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500">
iframe
</span>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium",
isInIframe
? "bg-violet-100 text-violet-700"
: "bg-gray-100 text-gray-500",
)}
>
{isInIframe ? "iframe 模式" : "独立页面"}
</span>
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-blue-50 text-xs text-blue-700 hover:bg-blue-100"
variant="ghost"
onClick={handleTestClipboardCopy}
>
📋
</Button>
<div className="rounded bg-gray-100 px-2 py-1.5 text-[10px] text-gray-600">
{isInIframe
? "将通过 postMessage 请求父页面复制"
: "将直接调用 navigator.clipboard"}
</div>
</div>
</div>
{/* 场景 5XClaw 使用状态 */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
XClaw 使
</div>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-100"
variant="ghost"
onClick={() => handleSendXClawUsed(true)}
>
used=true
</Button>
<Button
size="sm"
className="flex-1 bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
variant="ghost"
onClick={() => handleSendXClawUsed(false)}
>
used=false
</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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { UseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react";
import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
thread: BaseStream<AgentThreadState>;
thread: UseStream<AgentThreadState>;
threadId?: string;
isMock?: boolean;
}

View File

@ -79,7 +79,7 @@ export function MessageGroup({
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<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}
>
{aboveLastToolCallSteps.length > 0 && (
@ -215,7 +215,7 @@ function ToolCall({
<ChainOfThoughtSearchResults>
{result.map((item) => (
<ChainOfThoughtSearchResult key={item.url}>
<a href={item.url} target="_blank" rel="noopener noreferrer">
<a href={item.url} target="_blank" rel="noreferrer">
{item.title}
</a>
</ChainOfThoughtSearchResult>
@ -250,7 +250,7 @@ function ToolCall({
className="size-24 overflow-hidden rounded-lg object-cover"
href={item.source_url}
target="_blank"
rel="noopener noreferrer"
rel="noreferrer"
>
<div className="bg-accent size-24">
<img
@ -289,7 +289,7 @@ function ToolCall({
>
<ChainOfThoughtSearchResult>
{url && (
<a href={url} target="_blank" rel="noopener noreferrer">
<a href={url} target="_blank" rel="noreferrer">
{title}
</a>
)}

View File

@ -1,7 +1,6 @@
import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react";
import { useParams } from "next/navigation";
import { memo, useMemo, type ImgHTMLAttributes } from "react";
import { memo, useMemo, useState, type ImgHTMLAttributes } from "react";
import rehypeKatex from "rehype-katex";
import { Loader } from "@/components/ai-elements/loader";
@ -18,6 +17,7 @@ import {
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import {
@ -28,6 +28,7 @@ import {
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { materializeSkillYaml } from "@/core/skills";
import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
@ -39,26 +40,32 @@ export function MessageListItem({
className,
message,
isLoading,
threadId,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId?: string;
}) {
const isHuman = message.type === "human";
return (
<AIElementMessage
className={cn("group/conversation-message relative w-full", className)}
className={cn(
"group/conversation-message relative mb-1 w-full",
className,
)}
from={isHuman ? "user" : "assistant"}
>
<MessageContent
className={isHuman ? "w-fit" : "w-full"}
message={message}
isLoading={isLoading}
threadId={threadId}
/>
{!isLoading && (
<MessageToolbar
className={cn(
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
isHuman ? "-bottom-8 justify-end" : "-bottom-8",
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
)}
>
@ -87,7 +94,7 @@ function MessageImage({
maxWidth = "90%",
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
threadId?: string;
maxWidth?: string;
}) {
if (!src) return null;
@ -98,7 +105,8 @@ function MessageImage({
return <img className={imgClassName} src={src} alt={alt} {...props} />;
}
const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src;
const url =
src.startsWith("/mnt/") && threadId ? resolveArtifactURL(src, threadId) : src;
return (
<a href={url} target="_blank" rel="noopener noreferrer">
@ -111,21 +119,22 @@ function MessageContent_({
className,
message,
isLoading = false,
threadId,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId?: string;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human";
const { thread_id } = useParams<{ thread_id: string }>();
const components = useMemo(
() => ({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={thread_id} maxWidth="90%" />
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
}),
[thread_id],
[threadId],
);
const rawContent = extractContentFromMessage(message);
@ -151,8 +160,8 @@ function MessageContent_({
}, [rawContent, isHuman]);
const filesList =
files && files.length > 0 && thread_id ? (
<RichFilesList files={files} threadId={thread_id} />
files && files.length > 0 && threadId ? (
<RichFilesList files={files} threadId={threadId} />
) : null;
// Uploading state: mock AI message shown while files upload
@ -262,12 +271,20 @@ function isImageFile(filename: string): boolean {
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
}
function isYamlFile(filename: string): boolean {
const ext = getFileExt(filename);
return ext === "yaml" || ext === "yml";
}
/**
* Format bytes to human-readable size string
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const kb = bytes / 1024;
function formatBytes(bytes: number | string): string {
const numericBytes = typeof bytes === "string" ? Number(bytes) : bytes;
if (!Number.isFinite(numericBytes)) return "—";
const safeBytes = Math.max(0, numericBytes);
if (safeBytes === 0) return "—";
const kb = safeBytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}
@ -309,10 +326,15 @@ function RichFileCard({
const { t } = useI18n();
const isUploading = file.status === "uploading";
const isImage = isImageFile(file.filename);
const isYaml = isYamlFile(file.filename);
const [isMaterializing, setIsMaterializing] = useState(false);
const [materializeMessage, setMaterializeMessage] = useState<string | null>(
null,
);
if (isUploading) {
return (
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 opacity-60 shadow-sm">
<div className="bg-background border-border/40 flex max-w-[200px] min-w-[120px] flex-col gap-1 rounded-lg border p-3 opacity-60 shadow-sm">
<div className="flex items-start gap-2">
<Loader2Icon className="text-muted-foreground mt-0.5 size-4 shrink-0 animate-spin" />
<span
@ -341,6 +363,28 @@ function RichFileCard({
const fileUrl = resolveArtifactURL(file.path, threadId);
const handleMaterializeYaml = async () => {
if (!isYaml || isMaterializing) return;
setIsMaterializing(true);
setMaterializeMessage(null);
try {
const result = await materializeSkillYaml({
thread_id: threadId,
path: file.path!,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
setMaterializeMessage(
`已创建 ${result.created_files} 个文件 / ${result.created_directories} 个目录`,
);
} catch (error) {
const message = error instanceof Error ? error.message : "解析失败";
setMaterializeMessage(`失败: ${message}`);
} finally {
setIsMaterializing(false);
}
};
if (isImage) {
return (
<a
@ -352,14 +396,14 @@ function RichFileCard({
<img
src={fileUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
className="h-32 w-auto max-w-[240px] object-cover transition-transform group-hover:scale-105"
/>
</a>
);
}
return (
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
<div className="bg-background border-border/40 flex max-w-[200px] min-w-[120px] flex-col gap-1 rounded-lg border p-3 shadow-sm">
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
@ -380,6 +424,26 @@ function RichFileCard({
{formatBytes(file.size)}
</span>
</div>
{isYaml && (
<div className="mt-1 flex flex-col gap-1">
<Button
size="sm"
variant="secondary"
className="h-7 text-xs"
onClick={() => {
void handleMaterializeYaml();
}}
disabled={isMaterializing}
>
{isMaterializing ? "解析中..." : "一键导入为 Skill 目录"}
</Button>
{materializeMessage && (
<span className="text-muted-foreground text-[10px] leading-tight">
{materializeMessage}
</span>
)}
</div>
)}
</div>
);
}

View File

@ -1,8 +1,10 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { Message } from "@langchain/langgraph-sdk";
import type { UseStream } from "@langchain/langgraph-sdk/react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { useI18n } from "@/core/i18n/hooks";
import {
@ -29,43 +31,48 @@ import { MessageListItem } from "./message-list-item";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
export function MessageList({
className,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
messagesOverride,
suppressThreadLoading = false,
paddingBottom = 160,
showScrollToBottomButton = false,
scrollButtonClassName,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
threadId?: string;
thread: UseStream<AgentThreadState>;
/** When set (e.g. from onFinish), use instead of thread.messages so SSE end shows complete state. */
messagesOverride?: Message[];
suppressThreadLoading?: boolean;
paddingBottom?: number;
showScrollToBottomButton?: boolean;
scrollButtonClassName?: string;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
if (thread.isThreadLoading && messages.length === 0) {
const messages = messagesOverride ?? thread.messages;
if (thread.isThreadLoading && !suppressThreadLoading) {
return <MessageListSkeleton />;
}
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
<ConversationContent className="w-full gap-8 px-[20px]">
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
message={msg}
isLoading={thread.isLoading}
/>
);
});
return (
<MessageListItem
key={group.id}
message={group.messages[0]!}
isLoading={thread.isLoading}
threadId={threadId}
/>
);
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
@ -97,7 +104,9 @@ export function MessageList({
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
{threadId ? (
<ArtifactFileList files={files} threadId={threadId} />
) : null}
</div>
);
} else if (group.type === "assistant:subagent") {
@ -171,9 +180,9 @@ export function MessageList({
{t.subtasks.executing(tasks.size)}
</div>,
);
const taskIds = message.tool_calls
?.filter((toolCall) => toolCall.name === "task")
.map((toolCall) => toolCall.id);
const taskIds = message.tool_calls?.map(
(toolCall) => toolCall.id,
);
for (const taskId of taskIds ?? []) {
results.push(
<SubtaskCard
@ -201,9 +210,19 @@ export function MessageList({
/>
);
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
{thread.isLoading && messages.length > 0 && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
{/* showScrollToBottomButton */}
{ showScrollToBottomButton && (
<ConversationScrollButton
className={cn(
"z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm",
scrollButtonClassName,
)}
title="滚动到底部"
/>
)}
</Conversation>
);
}

View File

@ -1,79 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
const STAGGER_MS = 60;
function SkeletonBar({
className,
style,
originRight,
}: {
className?: string;
style?: React.CSSProperties;
originRight?: boolean;
}) {
return (
<div
className={`animate-skeleton-entrance fill-mode-[forwards] overflow-hidden rounded-md ${originRight ? "origin-[right]" : "origin-[left]"} ${className ?? ""}`}
style={{ opacity: 0, ...style }}
>
<Skeleton className="h-full w-full rounded-md" />
</div>
);
}
import { Loader } from "@/components/ai-elements/loader";
export function MessageListSkeleton() {
let index = 0;
return (
<div className="flex w-full max-w-(--container-width-md) flex-col gap-12 p-8 pt-16">
<div
role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end"
>
<SkeletonBar
className="h-6 w-full"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[80%]"
originRight
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
<div role="assistant-message" className="flex flex-col gap-2">
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[70%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-full"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[60%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
<SkeletonBar
className="h-6 w-[40%]"
style={{ animationDelay: `${index++ * STAGGER_MS}ms` }}
/>
</div>
<div className="flex w-full max-w-(--container-width-md) flex-1 items-center justify-center p-8">
<Loader
className="text-muted-foreground"
size={28}
role="status"
aria-label="Loading"
/>
</div>
);
}

View File

@ -56,7 +56,6 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { env } from "@/env";
import { isIMEComposing } from "@/lib/ime";
export function RecentChatList() {
const { t } = useI18n();
@ -272,8 +271,7 @@ export function RecentChatList() {
onChange={(e) => setRenameValue(e.target.value)}
placeholder={t.common.rename}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
if (e.key === "Enter") {
handleRenameSubmit();
}
}}

View File

@ -1,4 +1,4 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import type { UseStream } from "@langchain/langgraph-sdk/react";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
@ -10,14 +10,19 @@ import { FlipDisplay } from "./flip-display";
export function ThreadTitle({
threadId,
thread,
threadTitle,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
threadId?: string;
thread?: UseStream<AgentThreadState>;
threadTitle?: string;
}) {
const { t } = useI18n();
const { isNewThread } = useThreadChat();
useEffect(() => {
if (!thread) {
return;
}
let _title = t.pages.untitled;
if (thread.values?.title) {
@ -35,11 +40,15 @@ export function ThreadTitle({
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
thread,
thread?.isThreadLoading,
thread?.values,
]);
if (!thread.values?.title) {
if (threadTitle) {
return <FlipDisplay uniqueKey={threadTitle}>{threadTitle}</FlipDisplay>;
}
if (!thread?.values?.title || !threadId) {
return null;
}
return (

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

@ -1,15 +1,13 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { AuroraText } from "../ui/aurora-text";
let waved = false;
export function Welcome({
className,
mode,
@ -26,9 +24,6 @@ export function Welcome({
}
return ["var(--color-foreground)"];
}, [isUltra]);
useEffect(() => {
waved = true;
}, []);
return (
<div
className={cn(
@ -40,11 +35,16 @@ export function Welcome({
{searchParams.get("mode") === "skill" ? (
`${t.welcome.createYourOwnSkill}`
) : (
<div className="flex items-center gap-2">
<div className={cn("inline-block", !waved ? "animate-wave" : "")}>
{isUltra ? "🚀" : "👋"}
</div>
<AuroraText colors={colors}>{t.welcome.greeting}</AuroraText>
<div
className="flex items-center gap-2"
style={{ fontFamily: '"Microsoft YaHei"' }}
>
<AuroraText
className="text-center text-[18px] leading-normal font-normal"
colors={colors}
>
{t.welcome.greeting}
</AuroraText>
</div>
)}
</div>
@ -59,13 +59,7 @@ export function Welcome({
)}
</div>
) : (
<div className="text-muted-foreground text-sm">
{t.welcome.description.includes("\n") ? (
<pre className="whitespace-pre">{t.welcome.description}</pre>
) : (
<p>{t.welcome.description}</p>
)}
</div>
<div> </div>
)}
</div>
);

View File

@ -30,7 +30,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
{state === "collapsed" ? (
<div className="group-has-data-[collapsible=icon]/sidebar-wrapper:-translate-y flex w-full cursor-pointer items-center justify-center">
<div className="text-primary block pt-1 font-serif group-hover/workspace-header:hidden">
DF
XC
</div>
<SidebarTrigger className="hidden pl-2 group-hover/workspace-header:block" />
</div>
@ -38,11 +38,11 @@ export function WorkspaceHeader({ className }: { className?: string }) {
<div className="flex items-center justify-between gap-2">
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/" className="text-primary ml-2 font-serif">
DeerFlow
XClaw侧边栏
</Link>
) : (
<div className="text-primary ml-2 cursor-default font-serif">
DeerFlow
XClaw
</div>
)}
<SidebarTrigger />

View File

@ -29,7 +29,7 @@ export function WorkspaceSidebar({
{isSidebarOpen && <RecentChatList />}
</SidebarContent>
<SidebarFooter>
<WorkspaceNavMenu />
{/* <WorkspaceNavMenu /> */}
</SidebarFooter>
<SidebarRail />
</Sidebar>

View File

@ -2,18 +2,6 @@ import { getBackendBaseURL } from "@/core/config";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
export class AgentNameCheckError extends Error {
constructor(
message: string,
public readonly reason: "backend_unreachable" | "request_failed",
) {
super(message);
this.name = "AgentNameCheckError";
}
}
export async function listAgents(): Promise<Agent[]> {
const res = await fetch(`${getBackendBaseURL()}/api/agents`);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
@ -66,29 +54,13 @@ export async function deleteAgent(name: string): Promise<void> {
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
let res: Response;
try {
res = await fetch(
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
);
} catch {
throw new AgentNameCheckError(
"Could not reach the DeerFlow backend.",
"backend_unreachable",
);
}
const res = await fetch(
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
throw new AgentNameCheckError(
"Could not reach the DeerFlow backend.",
"backend_unreachable",
);
}
throw new AgentNameCheckError(
throw new Error(
err.detail ?? `Failed to check agent name: ${res.statusText}`,
"request_failed",
);
}
return res.json() as Promise<{ available: boolean; name: string }>;

View File

@ -11,7 +11,7 @@ export function useArtifactContent({
enabled,
}: {
filepath: string;
threadId: string;
threadId?: string;
enabled?: boolean;
}) {
const isWriteFile = useMemo(() => {
@ -25,19 +25,19 @@ export function useArtifactContent({
return null;
}, [filepath, isWriteFile, thread]);
const canFetch = Boolean(threadId) && enabled !== false;
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock });
return loadArtifactContent({
filepath,
threadId: threadId ?? "",
isMock,
});
},
enabled,
enabled: canFetch,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
});
return {
content: isWriteFile ? content : data?.content,
url: isWriteFile ? undefined : data?.url,
isLoading,
error,
};
return { content: isWriteFile ? content : data, isLoading, error };
}

View File

@ -20,7 +20,7 @@ export async function loadArtifactContent({
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
const response = await fetch(url);
const text = await response.text();
return { content: text, url };
return text;
}
export function loadArtifactContentFromToolCall({

View File

@ -4,15 +4,16 @@ function getBaseOrigin() {
if (typeof window !== "undefined") {
return window.location.origin;
}
// Fallback for SSR
return "http://localhost:2026";
return undefined;
}
export function getBackendBaseURL() {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin())
.toString()
.replace(/\/+$/, "");
return new URL(
env.NEXT_PUBLIC_BACKEND_BASE_URL,
getBaseOrigin(),
).toString();
} else {
return "";
}

View File

@ -4,15 +4,22 @@ import { useEffect } from "react";
import { useI18nContext } from "./context";
import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
import { translations } from "./translations";
import { enUS } from "./locales/en-US";
import { zhCN } from "./locales/zh-CN";
import {
DEFAULT_LOCALE,
detectLocale,
normalizeLocale,
type Locale,
type Translations,
} from "./index";
const translations: Record<Locale, Translations> = {
"en-US": enUS,
"zh-CN": zhCN,
};
export function useI18n() {
const { locale, setLocale } = useI18nContext();

View File

@ -6,16 +6,6 @@ export function isLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
export function getLocaleByLang(lang: string): Locale {
const normalizedLang = lang.toLowerCase();
for (const locale of SUPPORTED_LOCALES) {
if (locale.startsWith(normalizedLang)) {
return locale;
}
}
return DEFAULT_LOCALE;
}
export function normalizeLocale(locale: string | null | undefined): Locale {
if (!locale) {
return DEFAULT_LOCALE;

View File

@ -1,6 +1,5 @@
import {
CompassIcon,
GraduationCapIcon,
ImageIcon,
MicroscopeIcon,
PenLineIcon,
@ -22,14 +21,18 @@ export const enUS: Translations = {
home: "Home",
settings: "Settings",
delete: "Delete",
edit: "Edit",
rename: "Rename",
share: "Share",
fullScreen: "fullScreen",
closeFullScreen: "closeFullScreen",
openInNewWindow: "Open in new window",
close: "Close",
more: "More",
search: "Search",
download: "Download",
downloadOriginal: "Original File",
downloadAsDocx: "Download as DOCX",
downloadAsPdf: "Download as PDF",
thinking: "Thinking",
artifacts: "Artifacts",
public: "Public",
@ -44,17 +47,11 @@ export const enUS: Translations = {
save: "Save",
install: "Install",
create: "Create",
import: "Import",
export: "Export",
exportAsMarkdown: "Export as Markdown",
exportAsJSON: "Export as JSON",
exportSuccess: "Conversation exported",
},
// Home
home: {
docs: "Docs",
blog: "Blog",
removeAttachment: "Remove attachment",
},
// Welcome
@ -81,7 +78,10 @@ export const enUS: Translations = {
placeholder: "How can I assist you today?",
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?",
sendMessagePrice:
"Please note, this feature will consume tokens. Ensure your account balance is greater than 200 credits.",
addAttachments: "Add attachments",
selectSkill: "Select Skill",
mode: "Mode",
flashMode: "Flash",
flashModeDescription: "Fast and efficient, but may not be accurate",
@ -116,25 +116,38 @@ export const enUS: Translations = {
followupConfirmReplace: "Replace & send",
suggestions: [
{
suggestion: "Write",
prompt: "Write a blog post about the latest trends on [topic]",
icon: PenLineIcon,
},
{
suggestion: "Research",
suggestion: "Paper Writing",
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: "1245",
},
{
suggestion: "Report Generation",
prompt:
"Analyze [topic] in depth and generate a well-structured research report.",
icon: MicroscopeIcon,
skill_id: "520",
},
{
suggestion: "Collect",
prompt: "Collect data from [source] and create a report.",
suggestion: "Copywriting",
prompt:
"Create a complete planning proposal and promotional copy for [project/event].",
icon: ShapesIcon,
skill_id: "409",
},
{
suggestion: "Learn",
prompt: "Learn about [topic] and create a tutorial.",
icon: GraduationCapIcon,
suggestion: "Document Processing",
prompt:
"Process [document] with reading, summarizing, translating or format conversion.",
icon: CompassIcon,
skill_id: "5",
},
{
suggestion: "Market Research",
prompt: "TestingTestingTestingTestingTesting",
icon: ShapesIcon,
skill_id: "1216",
},
],
suggestionsCreate: [
@ -200,22 +213,9 @@ export const enUS: Translations = {
nameStepInvalidError:
"Invalid name — use only letters, digits, and hyphens",
nameStepAlreadyExistsError: "An agent with this name already exists",
nameStepNetworkError:
"Network request failed — check your network or backend connection",
nameStepCheckError: "Could not verify name availability — please try again",
nameStepBootstrapMessage:
"The new custom agent name is {name}. Let's bootstrap it's **SOUL**.",
save: "Save agent",
saving: "Saving agent...",
saveRequested:
"Save requested. DeerFlow is generating and saving an initial version now.",
saveHint:
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
saveCommandMessage:
"Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.",
agentCreatedPendingRefresh:
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
more: "More actions",
agentCreated: "Agent created!",
startChatting: "Start chatting",
backToGallery: "Back to Gallery",
@ -333,50 +333,6 @@ export const enUS: Translations = {
"DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.",
empty: "No memory data to display.",
rawJson: "Raw JSON",
exportButton: "Export memory",
exportSuccess: "Memory exported",
importButton: "Import memory",
importConfirmTitle: "Import memory?",
importConfirmDescription:
"This will overwrite your current memory with the selected JSON backup.",
importFileLabel: "Selected file",
importInvalidFile:
"Failed to read the selected memory file. Please choose a valid JSON export.",
importSuccess: "Memory imported",
manualFactSource: "Manual",
addFact: "Add fact",
addFactTitle: "Add memory fact",
editFactTitle: "Edit memory fact",
addFactSuccess: "Fact created",
editFactSuccess: "Fact updated",
clearAll: "Clear all memory",
clearAllConfirmTitle: "Clear all memory?",
clearAllConfirmDescription:
"This will remove all saved summaries and facts. This action cannot be undone.",
clearAllSuccess: "All memory cleared",
factDeleteConfirmTitle: "Delete this fact?",
factDeleteConfirmDescription:
"This fact will be removed from memory immediately. This action cannot be undone.",
factDeleteSuccess: "Fact deleted",
factContentLabel: "Content",
factCategoryLabel: "Category",
factConfidenceLabel: "Confidence",
factContentPlaceholder: "Describe the memory fact you want to save",
factCategoryPlaceholder: "context",
factConfidenceHint: "Use a number between 0 and 1.",
factSave: "Save fact",
factValidationContent: "Fact content cannot be empty.",
factValidationConfidence: "Confidence must be a number between 0 and 1.",
noFacts: "No saved facts yet.",
summaryReadOnly:
"Summary sections are read-only for now. You can currently add, edit, or delete individual facts, or clear all memory.",
memoryFullyEmpty: "No memory saved yet.",
factPreviewLabel: "Fact to delete",
searchPlaceholder: "Search memory",
filterAll: "All",
filterFacts: "Facts",
filterSummaries: "Summaries",
noMatches: "No matching memory found.",
markdown: {
overview: "Overview",
userContext: "User context",

View File

@ -11,14 +11,18 @@ export interface Translations {
home: string;
settings: string;
delete: string;
edit: string;
rename: string;
share: string;
openInNewWindow: string;
close: string;
fullScreen: string;
closeFullScreen: string;
more: string;
search: string;
download: string;
downloadOriginal: string;
downloadAsDocx: string;
downloadAsPdf: string;
thinking: string;
artifacts: string;
public: string;
@ -33,16 +37,11 @@ export interface Translations {
save: string;
install: string;
create: string;
import: string;
export: string;
exportAsMarkdown: string;
exportAsJSON: string;
exportSuccess: string;
};
home: {
docs: string;
blog: string;
removeAttachment: string;
};
// Welcome
@ -63,9 +62,11 @@ export interface Translations {
// Input Box
inputBox: {
sendMessagePrice: string;
placeholder: string;
createSkillPrompt: string;
addAttachments: string;
selectSkill: string;
mode: string;
flashMode: string;
flashModeDescription: string;
@ -96,6 +97,7 @@ export interface Translations {
suggestion: string;
prompt: string;
icon: LucideIcon;
skill_id?: string;
}[];
suggestionsCreate: (
| {
@ -138,16 +140,8 @@ export interface Translations {
nameStepContinue: string;
nameStepInvalidError: string;
nameStepAlreadyExistsError: string;
nameStepNetworkError: string;
nameStepCheckError: string;
nameStepBootstrapMessage: string;
save: string;
saving: string;
saveRequested: string;
saveHint: string;
saveCommandMessage: string;
agentCreatedPendingRefresh: string;
more: string;
agentCreated: string;
startChatting: string;
backToGallery: string;
@ -262,45 +256,6 @@ export interface Translations {
description: string;
empty: string;
rawJson: string;
exportButton: string;
exportSuccess: string;
importButton: string;
importConfirmTitle: string;
importConfirmDescription: string;
importFileLabel: string;
importInvalidFile: string;
importSuccess: string;
manualFactSource: string;
addFact: string;
addFactTitle: string;
editFactTitle: string;
addFactSuccess: string;
editFactSuccess: string;
clearAll: string;
clearAllConfirmTitle: string;
clearAllConfirmDescription: string;
clearAllSuccess: string;
factDeleteConfirmTitle: string;
factDeleteConfirmDescription: string;
factDeleteSuccess: string;
factContentLabel: string;
factCategoryLabel: string;
factConfidenceLabel: string;
factContentPlaceholder: string;
factCategoryPlaceholder: string;
factConfidenceHint: string;
factSave: string;
factValidationContent: string;
factValidationConfidence: string;
noFacts: string;
summaryReadOnly: string;
memoryFullyEmpty: string;
factPreviewLabel: string;
searchPlaceholder: string;
filterAll: string;
filterFacts: string;
filterSummaries: string;
noMatches: string;
markdown: {
overview: string;
userContext: string;

View File

@ -1,4 +1,4 @@
import {
import {
CompassIcon,
GraduationCapIcon,
ImageIcon,
@ -22,16 +22,20 @@ export const zhCN: Translations = {
home: "首页",
settings: "设置",
delete: "删除",
edit: "编辑",
rename: "重命名",
share: "分享",
openInNewWindow: "在新窗口打开",
close: "关闭",
fullScreen: "全屏",
closeFullScreen: "关闭全屏",
more: "更多",
search: "搜索",
download: "下载",
downloadOriginal: "原文件",
downloadAsDocx: "下载为 DOCX",
downloadAsPdf: "下载为 PDF",
thinking: "思考",
artifacts: "文件",
artifacts: "查看结果",
public: "公共",
custom: "自定义",
notAvailableInDemoMode: "在演示模式下不可用",
@ -44,22 +48,17 @@ export const zhCN: Translations = {
save: "保存",
install: "安装",
create: "创建",
import: "导入",
export: "导出",
exportAsMarkdown: "导出为 Markdown",
exportAsJSON: "导出为 JSON",
exportSuccess: "对话已导出",
},
// Home
home: {
docs: "文档",
blog: "博客",
removeAttachment: "移除附件",
},
// Welcome
welcome: {
greeting: "你好,欢迎回来!",
// TODO: 测试环境标识
greeting: "轻办公 · XClaw Tagv3.1.0 fix适配md图片的更多情况",
description:
"欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。",
@ -78,10 +77,13 @@ export const zhCN: Translations = {
// Input Box
inputBox: {
placeholder: "今天我能为你做些什么?",
placeholder: "先输入说明需求选择Skill开始使用吧",
createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
sendMessagePrice:
"请注意此功能将消耗token请保证账户余额大于200可学豆。",
addAttachments: "添加附件",
selectSkill: "选择Skill",
mode: "模式",
flashMode: "闪速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
@ -111,24 +113,35 @@ export const zhCN: Translations = {
followupConfirmReplace: "替换并发送",
suggestions: [
{
suggestion: "写作",
prompt: "撰写一篇关于[主题]的博客文章",
suggestion: "自媒体文案",
prompt:
"为[主题/产品]撰写吸引人的自媒体文案,包括标题、正文和话题标签。",
icon: PenLineIcon,
skill_id: "1245",
},
{
suggestion: "研究",
prompt: "深入浅出的研究一下[主题],并总结发现。",
icon: MicroscopeIcon,
suggestion: "需求文档",
prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。",
icon: CompassIcon,
skill_id: "520",
},
{
suggestion: "收集",
prompt: "从[来源]收集数据并创建报告。",
icon: ShapesIcon,
},
{
suggestion: "学习",
prompt: "学习关于[主题]并创建教程。",
suggestion: "使用指南",
prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。",
icon: GraduationCapIcon,
skill_id: "409",
},
{
suggestion: "Excel数据分析",
prompt: "对[Excel文件/数据]进行分析,生成数据洞察和可视化建议。",
icon: MicroscopeIcon,
skill_id: "5",
},
{
suggestion: "市场调研",
prompt: "针对[行业/产品]进行市场调研,分析市场规模、竞品和趋势。",
icon: ShapesIcon,
skill_id: "1216",
},
],
suggestionsCreate: [
@ -189,21 +202,9 @@ export const zhCN: Translations = {
nameStepContinue: "继续",
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
nameStepAlreadyExistsError: "已存在同名智能体",
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
nameStepCheckError: "无法验证名称可用性,请稍后重试",
nameStepBootstrapMessage:
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
save: "保存智能体",
saving: "正在保存智能体...",
saveRequested:
"已提交保存请求DeerFlow 正在根据当前对话生成并保存初版智能体。",
saveHint:
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
saveCommandMessage:
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md并直接调用 setup_agent不要再向我索要额外确认。",
agentCreatedPendingRefresh:
"智能体已创建,但 DeerFlow 暂时还无法读取到它。请稍后刷新当前页面。",
more: "更多操作",
agentCreated: "智能体已创建!",
startChatting: "开始对话",
backToGallery: "返回 Gallery",
@ -318,48 +319,6 @@ export const zhCN: Translations = {
"DeerFlow 会在后台不断从你的对话中自动学习。这些记忆能帮助 DeerFlow 更好地理解你,并提供更个性化的体验。",
empty: "暂无可展示的记忆数据。",
rawJson: "原始 JSON",
exportButton: "导出记忆",
exportSuccess: "记忆已导出",
importButton: "导入记忆",
importConfirmTitle: "导入记忆?",
importConfirmDescription: "这会用选中的 JSON 备份覆盖当前记忆。",
importFileLabel: "已选择文件",
importInvalidFile: "读取记忆文件失败,请选择有效的 JSON 导出文件。",
importSuccess: "记忆已导入",
manualFactSource: "手动添加",
addFact: "添加事实",
addFactTitle: "添加记忆事实",
editFactTitle: "编辑记忆事实",
addFactSuccess: "事实已创建",
editFactSuccess: "事实已更新",
clearAll: "清空全部记忆",
clearAllConfirmTitle: "要清空全部记忆吗?",
clearAllConfirmDescription:
"这会删除所有已保存的摘要和事实。此操作无法撤销。",
clearAllSuccess: "已清空全部记忆",
factDeleteConfirmTitle: "要删除这条事实吗?",
factDeleteConfirmDescription:
"这条事实会立即从记忆中删除。此操作无法撤销。",
factDeleteSuccess: "事实已删除",
factContentLabel: "内容",
factCategoryLabel: "类别",
factConfidenceLabel: "置信度",
factContentPlaceholder: "描述你想保存的记忆事实",
factCategoryPlaceholder: "context",
factConfidenceHint: "请输入 0 到 1 之间的数字。",
factSave: "保存事实",
factValidationContent: "事实内容不能为空。",
factValidationConfidence: "置信度必须是 0 到 1 之间的数字。",
noFacts: "还没有保存的事实。",
summaryReadOnly:
"摘要分区当前仍为只读。现在你可以清空全部记忆或删除单条事实。",
memoryFullyEmpty: "还没有保存任何记忆。",
factPreviewLabel: "即将删除的事实",
searchPlaceholder: "搜索记忆",
filterAll: "全部",
filterFacts: "事实",
filterSummaries: "摘要",
noMatches: "没有找到匹配的记忆。",
markdown: {
overview: "概览",
userContext: "用户上下文",

View File

@ -1,7 +1,6 @@
import { cookies } from "next/headers";
import { DEFAULT_LOCALE, normalizeLocale, type Locale } from "./locale";
import { translations } from "./translations";
import { normalizeLocale, type Locale } from "./locale";
export async function detectLocaleServer(): Promise<Locale> {
const cookieStore = await cookies();
@ -16,26 +15,3 @@ export async function detectLocaleServer(): Promise<Locale> {
return normalizeLocale(locale);
}
export async function setLocale(locale: string | Locale): Promise<Locale> {
const normalizedLocale = normalizeLocale(locale);
const cookieStore = await cookies();
cookieStore.set("locale", encodeURIComponent(normalizedLocale), {
maxAge: 365 * 24 * 60 * 60,
path: "/",
sameSite: "lax",
});
return normalizedLocale;
}
export async function getI18n(localeOverride?: string | Locale) {
const locale = localeOverride
? normalizeLocale(localeOverride)
: await detectLocaleServer();
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
return {
locale,
t,
};
}

View File

@ -1,148 +1,9 @@
import { getBackendBaseURL } from "../config";
import type {
MemoryFactInput,
MemoryFactPatchInput,
UserMemory,
} from "./types";
import type { UserMemory } from "./types";
async function readMemoryResponse(
response: Response,
fallbackMessage: string,
): Promise<UserMemory> {
function formatErrorDetail(detail: unknown): string | null {
if (typeof detail === "string") {
return detail;
}
if (Array.isArray(detail)) {
const parts = detail
.map((item) => {
if (typeof item === "string") {
return item;
}
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
if (typeof record.msg === "string") {
return record.msg;
}
try {
return JSON.stringify(record);
} catch {
return null;
}
}
return String(item);
})
.filter(Boolean);
return parts.length > 0 ? parts.join("; ") : null;
}
if (detail && typeof detail === "object") {
try {
return JSON.stringify(detail);
} catch {
return null;
}
}
if (
typeof detail === "string" ||
typeof detail === "number" ||
typeof detail === "boolean" ||
typeof detail === "bigint"
) {
return String(detail);
}
if (typeof detail === "symbol") {
return detail.description ?? null;
}
return null;
}
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
const detailMessage = formatErrorDetail(errorData.detail);
throw new Error(
detailMessage ?? `${fallbackMessage}: ${response.statusText}`,
);
}
return response.json() as Promise<UserMemory>;
}
export async function loadMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory`);
return readMemoryResponse(response, "Failed to fetch memory");
}
export async function clearMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory`, {
method: "DELETE",
});
return readMemoryResponse(response, "Failed to clear memory");
}
export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
const response = await fetch(
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
{
method: "DELETE",
},
);
return readMemoryResponse(response, "Failed to delete memory fact");
}
export async function exportMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory/export`);
return readMemoryResponse(response, "Failed to export memory");
}
export async function importMemory(memory: UserMemory): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(memory),
});
return readMemoryResponse(response, "Failed to import memory");
}
export async function createMemoryFact(
input: MemoryFactInput,
): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return readMemoryResponse(response, "Failed to create memory fact");
}
export async function updateMemoryFact(
factId: string,
input: MemoryFactPatchInput,
): Promise<UserMemory> {
const response = await fetch(
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
},
);
return readMemoryResponse(response, "Failed to update memory fact");
export async function loadMemory() {
const memory = await fetch(`${getBackendBaseURL()}/api/memory`);
const json = await memory.json();
return json as UserMemory;
}

View File

@ -1,18 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
clearMemory,
createMemoryFact,
deleteMemoryFact,
importMemory,
loadMemory,
updateMemoryFact,
} from "./api";
import type {
MemoryFactInput,
MemoryFactPatchInput,
UserMemory,
} from "./types";
import { loadMemory } from "./api";
export function useMemory() {
const { data, isLoading, error } = useQuery({
@ -21,64 +9,3 @@ export function useMemory() {
});
return { memory: data ?? null, isLoading, error };
}
export function useClearMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => clearMemory(),
onSuccess: (memory) => {
queryClient.setQueryData<UserMemory>(["memory"], memory);
},
});
}
export function useDeleteMemoryFact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (factId: string) => deleteMemoryFact(factId),
onSuccess: (memory) => {
queryClient.setQueryData<UserMemory>(["memory"], memory);
},
});
}
export function useImportMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (memory: UserMemory) => importMemory(memory),
onSuccess: (memory) => {
queryClient.setQueryData<UserMemory>(["memory"], memory);
},
});
}
export function useCreateMemoryFact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: MemoryFactInput) => createMemoryFact(input),
onSuccess: (memory) => {
queryClient.setQueryData<UserMemory>(["memory"], memory);
},
});
}
export function useUpdateMemoryFact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
factId,
input,
}: {
factId: string;
input: MemoryFactPatchInput;
}) => updateMemoryFact(factId, input),
onSuccess: (memory) => {
queryClient.setQueryData<UserMemory>(["memory"], memory);
},
});
}

View File

@ -1,24 +1,3 @@
export interface MemoryFact {
id: string;
content: string;
category: string;
confidence: number;
createdAt: string;
source: string;
}
export interface MemoryFactInput {
content: string;
category: string;
confidence: number;
}
export interface MemoryFactPatchInput {
content?: string;
category?: string;
confidence?: number;
}
export interface UserMemory {
version: string;
lastUpdated: string;
@ -50,5 +29,12 @@ export interface UserMemory {
updatedAt: string;
};
};
facts: MemoryFact[];
facts: {
id: string;
content: string;
category: string;
confidence: number;
createdAt: string;
source: string;
}[];
}

View File

@ -52,10 +52,6 @@ export function groupMessages<T>(
}
for (const message of messages) {
if (isHiddenFromUIMessage(message)) {
continue;
}
if (message.name === "todo_reminder") {
continue;
}
@ -327,10 +323,6 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
return undefined;
}
export function isHiddenFromUIMessage(message: Message) {
return message.additional_kwargs?.hide_from_ui === true;
}
/**
* Represents a file stored in message additional_kwargs.files.
* Used for optimistic UI (uploading state) and structured file metadata.

View File

@ -3,9 +3,6 @@ import { useMemo } from "react";
import { visit } from "unist-util-visit";
import type { BuildVisitor } from "unist-util-visit";
const CJK_TEXT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
export function rehypeSplitWordsIntoSpans() {
return (tree: Root) => {
visit(tree, "element", ((node: Element) => {
@ -18,10 +15,6 @@ export function rehypeSplitWordsIntoSpans() {
const newChildren: Array<ElementContent> = [];
node.children.forEach((child) => {
if (child.type === "text") {
if (CJK_TEXT_RE.test(child.value)) {
newChildren.push(child);
return;
}
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const segments = segmenter.segment(child.value);
const words = Array.from(segments)

View File

@ -3,62 +3,44 @@ import { useCallback, useLayoutEffect, useState } from "react";
import {
DEFAULT_LOCAL_SETTINGS,
getLocalSettings,
getThreadLocalSettings,
saveLocalSettings,
saveThreadLocalSettings,
type LocalSettings,
} from "./local";
type LocalSettingsSetter = (
key: keyof LocalSettings,
value: Partial<LocalSettings[keyof LocalSettings]>,
) => void;
function useSettingsState(
getSettings: () => LocalSettings,
saveSettings: (settings: LocalSettings) => void,
): [LocalSettings, LocalSettingsSetter] {
const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS);
export function useLocalSettings(): [
LocalSettings,
(
key: keyof LocalSettings,
value: Partial<LocalSettings[keyof LocalSettings]>,
) => void,
] {
const [mounted, setMounted] = useState(false);
const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS);
useLayoutEffect(() => {
setState(getSettings());
if (!mounted) {
setState(getLocalSettings());
}
setMounted(true);
}, [getSettings]);
const setter = useCallback<LocalSettingsSetter>(
(key, value) => {
}, [mounted]);
const setter = useCallback(
(
key: keyof LocalSettings,
value: Partial<LocalSettings[keyof LocalSettings]>,
) => {
if (!mounted) return;
setState((prev) => {
const newState: LocalSettings = {
const newState = {
...prev,
[key]: {
...prev[key],
...value,
},
};
saveSettings(newState);
saveLocalSettings(newState);
return newState;
});
},
[mounted, saveSettings],
[mounted],
);
return [state, setter];
}
export function useLocalSettings(): [LocalSettings, LocalSettingsSetter] {
return useSettingsState(getLocalSettings, saveLocalSettings);
}
export function useThreadSettings(
threadId: string,
): [LocalSettings, LocalSettingsSetter] {
return useSettingsState(
useCallback(() => getThreadLocalSettings(threadId), [threadId]),
useCallback(
(settings: LocalSettings) => saveThreadLocalSettings(threadId, settings),
[threadId],
),
);
}

View File

@ -15,11 +15,6 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
};
const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
function isBrowser(): boolean {
return typeof window !== "undefined";
}
export interface LocalSettings {
notification: {
@ -27,14 +22,8 @@ export interface LocalSettings {
};
context: Omit<
AgentThreadContext,
| "thread_id"
| "is_plan_mode"
| "thinking_enabled"
| "subagent_enabled"
| "model_name"
| "reasoning_effort"
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
model_name?: string | undefined;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
};
@ -43,96 +32,35 @@ export interface LocalSettings {
};
}
function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
return {
...DEFAULT_LOCAL_SETTINGS,
context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings?.context,
},
layout: {
...DEFAULT_LOCAL_SETTINGS.layout,
...settings?.layout,
},
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
};
}
function getThreadModelStorageKey(threadId: string): string {
return `${THREAD_MODEL_KEY_PREFIX}${threadId}`;
}
export function getThreadModelName(threadId: string): string | undefined {
if (!isBrowser()) {
return undefined;
}
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
}
export function saveThreadModelName(
threadId: string,
modelName: string | undefined,
) {
if (!isBrowser()) {
return;
}
const key = getThreadModelStorageKey(threadId);
if (!modelName) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, modelName);
}
function applyThreadModelOverride(
settings: LocalSettings,
threadId?: string,
): LocalSettings {
const threadModelName = threadId ? getThreadModelName(threadId) : undefined;
if (!threadModelName) {
return settings;
}
return {
...settings,
context: {
...settings.context,
model_name: threadModelName,
},
};
}
export function getLocalSettings(): LocalSettings {
if (!isBrowser()) {
if (typeof window === "undefined") {
return DEFAULT_LOCAL_SETTINGS;
}
const json = localStorage.getItem(LOCAL_SETTINGS_KEY);
try {
if (json) {
const settings = JSON.parse(json) as Partial<LocalSettings>;
return mergeLocalSettings(settings);
const settings = JSON.parse(json);
const mergedSettings = {
...DEFAULT_LOCAL_SETTINGS,
context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings.context,
},
layout: {
...DEFAULT_LOCAL_SETTINGS.layout,
...settings.layout,
},
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings.notification,
},
};
return mergedSettings;
}
} catch {}
return DEFAULT_LOCAL_SETTINGS;
}
export function getThreadLocalSettings(threadId: string): LocalSettings {
return applyThreadModelOverride(getLocalSettings(), threadId);
}
export function saveLocalSettings(settings: LocalSettings) {
if (!isBrowser()) {
return;
}
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
}
export function saveThreadLocalSettings(
threadId: string,
settings: LocalSettings,
) {
saveLocalSettings(settings);
saveThreadModelName(threadId, settings.context.model_name);
}

View File

@ -29,6 +29,8 @@ export interface ListFilesResponse {
count: number;
}
export type UploadTarget = "skill";
async function readErrorDetail(
response: Response,
fallback: string,
@ -43,6 +45,7 @@ async function readErrorDetail(
export async function uploadFiles(
threadId: string,
files: File[],
options?: { target?: UploadTarget },
): Promise<UploadResponse> {
const formData = new FormData();
@ -50,6 +53,10 @@ export async function uploadFiles(
formData.append("files", file);
});
if (options?.target) {
formData.append("upload_target", options.target);
}
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
{

View File

@ -3,6 +3,4 @@
*/
export * from "./api";
export * from "./file-validation";
export * from "./hooks";
export * from "./prompt-input-files";

View File

@ -72,7 +72,8 @@
@theme {
--font-sans:
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Microsoft YaHei", "微软雅黑", var(--font-geist-sans), ui-sans-serif,
system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1.1s;
@ -185,6 +186,7 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-tooltip-background: var(--tooltip-background);
--animate-aurora: aurora 8s ease-in-out infinite alternate;
@keyframes aurora {
0% {
@ -224,19 +226,25 @@
:root {
--radius: 0.625rem;
--background: oklch(0.9855 0.0098 87.47);
--background: #f9f8fa;
--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);
--popover: oklch(1 0.0098 87.47);
/* --popover: oklch(1 0.0098 87.47); */
--popover: #ffffff;
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0 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);
--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);
--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);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.0098 87.47);
@ -255,6 +263,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0.0098 87.47);
--sidebar-ring: oklch(0.708 0 0);
--tooltip-background: #00000066;
}
.dark {
@ -289,6 +298,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--tooltip-background: oklch(0.85 0 0);
font-weight: 300;
}
@ -297,7 +307,7 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply text-foreground;
}
.container-md {
@ -384,9 +394,94 @@
}
}
/* Hide scrollbar but keep scroll behavior */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* Chrome, Safari, Opera */
/* *::-webkit-scrollbar {
display: none;
} */
:root {
--container-width-xs: calc(var(--spacing) * 72);
--container-width-sm: calc(var(--spacing) * 144);
--container-width-md: calc(var(--spacing) * 204);
--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));
}
/* 特别指定,代码块和正文一样的字体 */
code,
kbd,
samp,
pre {
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
}
/* 列表项 - 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));
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.ͼ4s {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.cm-content {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.cm-scroller {
min-width: 0;
}
.cm-editor {
overflow: hidden;
contain: paint;
}