diff --git a/frontend/src/app/workspace/agents/new/page.tsx b/frontend/src/app/workspace/agents/new/page.tsx index 33f6de21..9424a5f5 100644 --- a/frontend/src/app/workspace/agents/new/page.tsx +++ b/frontend/src/app/workspace/agents/new/page.tsx @@ -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("name"); const [nameInput, setNameInput] = useState(""); const [nameError, setNameError] = useState(""); const [isCheckingName, setIsCheckingName] = useState(false); const [agentName, setAgentName] = useState(""); const [agent, setAgent] = useState(null); - const [showSaveHint, setShowSaveHint] = useState(false); - const [setupAgentStatus, setSetupAgentStatus] = - useState("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) => { - 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 = ( -
-
- -

{t.agents.createPageTitle}

-
- - {step === "chat" ? ( - - - - - - void handleSaveAgent()} - disabled={ - !!agent || thread.isLoading || setupAgentStatus !== "idle" - } - > - - {setupAgentStatus === "requested" - ? t.agents.saving - : t.agents.save} - - - - ) : null} +
+ +

{t.agents.createPageTitle}

); + // ── Step 1: name form ────────────────────────────────────────────────────── + if (step === "name") { return (
@@ -286,9 +166,9 @@ export default function NewAgentPage() { onKeyDown={handleNameKeyDown} className={cn(nameError && "border-destructive")} /> - {nameError ? ( + {nameError && (

{nameError}

- ) : null} + )} + + + + +
- - {attachmentLabel} - - - -
- {isImage && ( -
- {filename -
- )} -
-
-

- {filename || (isImage ? "Image" : "Attachment")} -

- {data.mediaType && ( -

- {data.mediaType} -

- )} -
+ + ) : ( + <> +
+ + + {truncateFilename(filename)} +
-
- - + {/* 关闭按钮 - 右上角 */} + + + )} +
); } @@ -386,7 +411,7 @@ export type PromptInputAttachmentsProps = Omit< HTMLAttributes, "children" > & { - children: (attachment: PromptInputFilePart & { id: string }) => ReactNode; + children: (attachment: FileUIPart & { id: string }) => ReactNode; }; export function PromptInputAttachments({ @@ -402,13 +427,14 @@ export function PromptInputAttachments({ return (
{attachments.files.map((file) => ( - -
{children(file)}
-
+ {children(file)} ))}
); @@ -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, ) => void | Promise; + // 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(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 = (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 = (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 = ( - + <> - {children} + {children} - + ); 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 = (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 & { 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 = ; + let text: string = "发送"; + if (status === "submitted") { Icon = ; + text = "生成中..."; } else if (status === "streaming") { Icon = ; + text = "停止"; } else if (status === "error") { + // 没有报错状态,先用error状态代替 Icon = ; + // MARK: 这里后端没有返回错误信息,先写死一个文本 + text = "发送"; } return ( - - {children ?? Icon} - + + + {/* {children ?? Icon} */} + {text} + + ); }; @@ -1176,8 +1202,6 @@ export const PromptInputSpeechButton = ({ null, ); const recognitionRef = useRef(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) { diff --git a/frontend/src/components/ai-elements/sources.tsx b/frontend/src/components/ai-elements/sources.tsx index dd0aa623..f3570f9b 100644 --- a/frontend/src/components/ai-elements/sources.tsx +++ b/frontend/src/components/ai-elements/sources.tsx @@ -63,7 +63,7 @@ export const Source = ({ href, title, children, ...props }: SourceProps) => (
diff --git a/frontend/src/components/ai-elements/suggestion.tsx b/frontend/src/components/ai-elements/suggestion.tsx index fe12ae2c..a7f3b033 100644 --- a/frontend/src/components/ai-elements/suggestion.tsx +++ b/frontend/src/components/ai-elements/suggestion.tsx @@ -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 ( ); diff --git a/frontend/src/components/landing/header.tsx b/frontend/src/components/landing/header.tsx index 3941ac79..7e4afa43 100644 --- a/frontend/src/components/landing/header.tsx +++ b/frontend/src/components/landing/header.tsx @@ -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 ( -
-
- +
+ -
- + Star on GitHub {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && diff --git a/frontend/src/components/landing/sections/case-study-section.tsx b/frontend/src/components/landing/sections/case-study-section.tsx index 6a7cc495..0ae2f667 100644 --- a/frontend/src/components/landing/sections/case-study-section.tsx +++ b/frontend/src/components/landing/sections/case-study-section.tsx @@ -57,7 +57,6 @@ export function CaseStudySection({ className }: { className?: string }) { key={caseStudy.title} href={pathOfThread(caseStudy.threadId) + "?mock=true"} target="_blank" - rel="noopener noreferrer" >