feat(03): align workspace visual layer with legacy baseline
This commit is contained in:
parent
981bb8f005
commit
7012693802
|
|
@ -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 {
|
||||
} 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,47 +114,13 @@ 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">
|
||||
<header className="flex shrink-0 items-center gap-3 border-b px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
|
@ -227,33 +129,11 @@ export default function NewAgentPage() {
|
|||
<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>
|
||||
);
|
||||
|
||||
// ── 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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"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}
|
||||
>
|
||||
<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}
|
||||
className="size-full object-cover"
|
||||
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="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>
|
||||
</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}
|
||||
<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>
|
||||
)}
|
||||
<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 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>
|
||||
{/* 关闭按钮 - 右上角 */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</PromptInputHoverCardContent>
|
||||
</PromptInputHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<Tooltip content={t.inputBox.sendMessagePrice}>
|
||||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
className={cn(className)}
|
||||
// 被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}
|
||||
{/* {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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -18,11 +11,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
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={
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,14 +114,14 @@ export function ArtifactFileList({
|
|||
{t.common.install}
|
||||
</Button>
|
||||
)}
|
||||
{threadId ? (
|
||||
<a
|
||||
href={urlOfArtifact({
|
||||
filepath: file,
|
||||
threadId: threadId,
|
||||
threadId,
|
||||
download: true,
|
||||
})}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button variant="ghost">
|
||||
|
|
@ -119,6 +129,12 @@ export function ArtifactFileList({
|
|||
{t.common.download}
|
||||
</Button>
|
||||
</a>
|
||||
) : (
|
||||
<Button variant="ghost" disabled>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.common.download}
|
||||
</Button>
|
||||
)}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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+";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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=skill,URL 已更新");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 场景 2:skill 选择通信 */}
|
||||
<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>
|
||||
|
||||
{/* 场景 5:XClaw 使用状态 */}
|
||||
<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
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` }}
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function WorkspaceSidebar({
|
|||
{isSidebarOpen && <RecentChatList />}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
{/* <WorkspaceNavMenu /> */}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
const res = await fetch(
|
||||
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
|
||||
);
|
||||
} catch {
|
||||
throw new AgentNameCheckError(
|
||||
"Could not reach the DeerFlow backend.",
|
||||
"backend_unreachable",
|
||||
);
|
||||
}
|
||||
|
||||
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 }>;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 Tag:v3.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: "用户上下文",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -3,62 +3,44 @@ import { useCallback, useLayoutEffect, useState } from "react";
|
|||
import {
|
||||
DEFAULT_LOCAL_SETTINGS,
|
||||
getLocalSettings,
|
||||
getThreadLocalSettings,
|
||||
saveLocalSettings,
|
||||
saveThreadLocalSettings,
|
||||
type LocalSettings,
|
||||
} from "./local";
|
||||
|
||||
type LocalSettingsSetter = (
|
||||
export function useLocalSettings(): [
|
||||
LocalSettings,
|
||||
(
|
||||
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);
|
||||
|
||||
) => 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],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,4 @@
|
|||
*/
|
||||
|
||||
export * from "./api";
|
||||
export * from "./file-validation";
|
||||
export * from "./hooks";
|
||||
export * from "./prompt-input-files";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue