feat(ui): 详细对齐第一版
This commit is contained in:
parent
a721091476
commit
e1cd6fc3ca
|
|
@ -156,7 +156,6 @@ export default function AgentChatPage() {
|
|||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ export default function ChatPage() {
|
|||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ export default function AgentChatPage() {
|
|||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
|
|
|
|||
|
|
@ -1,157 +1,561 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
DevDialog,
|
||||
DevDialogContent,
|
||||
DevDialogFooter,
|
||||
DevDialogHeader,
|
||||
DevDialogTitle,
|
||||
} from "@/components/ui/dev-dialog";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import { DevTodoList } from "@/components/workspace/dev-todo-list";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { bootstrapRemoteSkill } from "@/core/skills";
|
||||
import { type AgentThread, type AgentThreadState } from "@/core/threads";
|
||||
import {
|
||||
useSubmitThread,
|
||||
useThreadStreamLegacy as useThreadStream,
|
||||
} from "@/core/threads/hooks";
|
||||
import {
|
||||
pathOfThread,
|
||||
textOfMessage,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { env } from "@/env";
|
||||
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
|
||||
const router = useRouter();
|
||||
useSpecificChatMode();
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { setOpen: setSidebarOpen } = useSidebar();
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
selectedArtifact,
|
||||
fullscreen,
|
||||
} = useArtifacts();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const promptInputController = usePromptInputController();
|
||||
|
||||
// UI mode depends only on route: /workspace/chats/new is always "new page" mode.
|
||||
const isNewThread = useMemo(
|
||||
() => threadIdFromPath === "new",
|
||||
[threadIdFromPath],
|
||||
);
|
||||
|
||||
// Submission strategy is controlled by `isnew` query param only.
|
||||
// - isnew=false: reuse existing thread
|
||||
// - otherwise: create/start a new session
|
||||
const createNewSession = useMemo(() => {
|
||||
if (threadIdFromPath !== "new") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return searchParams.get("isnew")?.trim().toLowerCase() !== "false";
|
||||
}, [threadIdFromPath, searchParams]);
|
||||
|
||||
const uploadTarget = useMemo(() => {
|
||||
const target = searchParams.get("upload_target")?.trim().toLowerCase();
|
||||
return target === "skill" ? "skill" : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (threadIdFromPath !== "new") {
|
||||
setThreadId(threadIdFromPath);
|
||||
} else {
|
||||
const queryThreadId = searchParams.get("thread_id")?.trim();
|
||||
setThreadId(queryThreadId ?? uuid());
|
||||
}
|
||||
}, [threadIdFromPath, searchParams]);
|
||||
|
||||
// Runtime strategy for /new page:
|
||||
// - UI remains new-page mode
|
||||
// - if isnew=false, execute against existing thread_id without creating a new one
|
||||
const reuseExistingThread = useMemo(
|
||||
() => threadIdFromPath === "new" && !createNewSession && !!threadId,
|
||||
[threadIdFromPath, createNewSession, threadId],
|
||||
);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: () => {
|
||||
setIsNewThread(false);
|
||||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||||
history.replaceState(null, "", `/workspace/chats/${threadId}`);
|
||||
},
|
||||
// 监听宿主页 selectedSkill 消息
|
||||
const {
|
||||
selectedSkill,
|
||||
skillError: selectedSkillError,
|
||||
clearSkillError: clearSelectedSkillError,
|
||||
isBootstrapping: isSelectedSkillBootstrapping,
|
||||
} = useSelectedSkillListener({ threadId });
|
||||
const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
|
||||
const thread = useThreadStream({
|
||||
// Keep UI in new-page mode, but runtime may reuse existing thread
|
||||
isNewThread: reuseExistingThread ? false : isNewThread,
|
||||
threadId,
|
||||
fetchStateHistory: true,
|
||||
onFinish: (state) => {
|
||||
setFinalState(state);
|
||||
// 新对话完成后导航到对话页面
|
||||
if (isNewThread && threadId) {
|
||||
router.push(pathOfThread(threadId));
|
||||
}
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
if (textContent.length > 200) {
|
||||
body = textContent.substring(0, 200) + "...";
|
||||
} else {
|
||||
body = textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
showNotification(state.title, {
|
||||
body,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}) as unknown as UseStream<AgentThreadState>;
|
||||
useEffect(() => {
|
||||
if (thread.isLoading) setFinalState(null);
|
||||
}, [thread.isLoading]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
void sendMessage(threadId, message);
|
||||
const title = useMemo(() => {
|
||||
let result = isNewThread
|
||||
? ""
|
||||
: titleOfThread(thread as unknown as AgentThread);
|
||||
if (result === "Untitled") {
|
||||
result = "";
|
||||
}
|
||||
return result;
|
||||
}, [thread, isNewThread]);
|
||||
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted;
|
||||
|
||||
useEffect(() => {
|
||||
const pageTitle = isNewThread
|
||||
? t.pages.newChat
|
||||
: thread.values?.title && thread.values.title !== "Untitled"
|
||||
? thread.values.title
|
||||
: t.pages.untitled;
|
||||
if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) {
|
||||
document.title = `Loading... - ${t.pages.appName}`;
|
||||
} else {
|
||||
document.title = `${pageTitle} - ${t.pages.appName}`;
|
||||
}
|
||||
}, [
|
||||
isNewThread,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.values.title,
|
||||
thread.isThreadLoading,
|
||||
suppressExistingThreadPrefetchUi,
|
||||
]);
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
setArtifacts(thread.values.artifacts);
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autoSelectFirstArtifact,
|
||||
selectArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
|
||||
const [todoListCollapsed, setTodoListCollapsed] = useState(true);
|
||||
const [showExitDialog, setShowExitDialog] = useState(false);
|
||||
|
||||
const submitThread = useSubmitThread({
|
||||
isNewThread,
|
||||
createNewSession,
|
||||
threadId,
|
||||
thread,
|
||||
uploadTarget,
|
||||
threadContext: {
|
||||
...settings.context,
|
||||
thinking_enabled: settings.context.mode !== "flash",
|
||||
is_plan_mode:
|
||||
settings.context.mode === "pro" || settings.context.mode === "ultra",
|
||||
subagent_enabled: settings.context.mode === "ultra",
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
afterSubmit() {
|
||||
// 导航已在 onFinish 中处理,确保 stream 完成后再导航
|
||||
},
|
||||
});
|
||||
const handleSubmit = useCallback(
|
||||
(message: Parameters<typeof submitThread>[0]) => {
|
||||
if (isSelectedSkillBootstrapping) {
|
||||
return;
|
||||
}
|
||||
setHasSubmitted(true);
|
||||
void submitThread(message);
|
||||
},
|
||||
[isSelectedSkillBootstrapping, submitThread],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<div
|
||||
className={cn(
|
||||
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
|
||||
artifactsOpen ? "w-full" : "w-[70%]",
|
||||
)}
|
||||
>
|
||||
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
|
||||
isNewThread
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
"relative overflow-hidden rounded-t-[20px] transition-all duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "w-[50%]" : "w-full",
|
||||
fullscreen && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenUsageIndicator messages={thread.messages} />
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isNewThread && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
|
||||
<header
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isNewThread && "-translate-y-[calc(50vh-96px)]",
|
||||
isNewThread
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
"bg-background absolute top-0 right-0 left-0 z-30 mx-4 grid h-[58px] shrink-0 grid-cols-3 items-center border-b transition-all duration-300 ease-in-out",
|
||||
isNewThread && !hasSubmitted ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-start overflow-hidden text-sm font-medium">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
|
||||
onClick={() => setShowExitDialog(true)}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
|
||||
stroke="#666666"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isNewThread && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
<div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]">
|
||||
{title !== "Untitled" && (
|
||||
<ThreadTitle threadId={threadId} threadTitle={title} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
||||
<TokenUsageIndicator messages={thread.messages} />
|
||||
<DevTodoList
|
||||
className="bg-white"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-full px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]"
|
||||
>
|
||||
<ListTodoIcon className="size-4" /> To-dos
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{artifacts?.length > 0 && !artifactsOpen && (
|
||||
<Tooltip content="点击可查看生成的文件结果">
|
||||
<Button
|
||||
className="text-[#150033] hover:text-[#150033]/80"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(true);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<FilesIcon />
|
||||
{t.common.artifacts}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
className={cn(
|
||||
"flex min-h-0 max-w-full grow flex-col",
|
||||
isNewThread && !hasSubmitted ? "bg-white" : "bg-background",
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn(
|
||||
"size-full",
|
||||
(!isNewThread || hasSubmitted) && "pt-[58px]",
|
||||
)}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
// suppressThreadLoading={suppressExistingThreadPrefetchUi}
|
||||
// messagesOverride={
|
||||
// suppressExistingThreadPrefetchUi
|
||||
// ? []
|
||||
// : !thread.isLoading && finalState?.messages
|
||||
// ? (finalState.messages as Message[])
|
||||
// : undefined
|
||||
// }
|
||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background ml-[20px] rounded-t-[20px] transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
artifactPanelOpen
|
||||
? fullscreen
|
||||
? "ml-0 w-full"
|
||||
: "w-[50%]"
|
||||
: "w-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center px-[20px]">
|
||||
<div className="absolute top-2 right-2 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-[14px] font-bold text-[#333333]">
|
||||
{t.common.artifacts}
|
||||
</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatBox>
|
||||
|
||||
{/* Fixed 底部居中输入框容器 */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
fullscreen ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto relative w-full max-w-[720px]",
|
||||
isNewThread && !hasSubmitted && "-translate-y-[calc(50vh-96px)]",
|
||||
)}
|
||||
>
|
||||
<InputBox
|
||||
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
|
||||
isNewThread={isNewThread}
|
||||
hasSubmitted={hasSubmitted}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
suppressExistingThreadPrefetchUi
|
||||
? "ready"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
<div className="flex flex-col gap-4">
|
||||
{isNewThread && !hasSubmitted && (
|
||||
<Welcome mode={settings.context.mode} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isSelectedSkillBootstrapping
|
||||
}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
|
||||
{/* {isSelectedSkillBootstrapping && (
|
||||
<div className="text-muted-foreground w-full translate-y-8 text-center text-xs">
|
||||
正在初始化 Skill 文件...
|
||||
</div>
|
||||
)} */}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 退出确认对话框 */}
|
||||
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}>
|
||||
<DevDialogContent>
|
||||
<DevDialogHeader>
|
||||
<DevDialogTitle>提示</DevDialogTitle>
|
||||
</DevDialogHeader>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
退出后,当前会话结束并销毁,请先下载保存当前结果!
|
||||
</p>
|
||||
<DevDialogFooter>
|
||||
<Button
|
||||
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
||||
variant="ghost"
|
||||
onClick={() => setShowExitDialog(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
// 如果正在生成,先终止再退出
|
||||
if (thread.isLoading) {
|
||||
await handleStop();
|
||||
}
|
||||
setShowExitDialog(false);
|
||||
// 使用完整页面刷新确保组件重新挂载,isNewThread 为 true
|
||||
window.location.href = "/workspace/chats/new";
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</DevDialogFooter>
|
||||
</DevDialogContent>
|
||||
</DevDialog>
|
||||
|
||||
{/* selectedSkill 失败:错误弹窗 */}
|
||||
<DevDialog
|
||||
open={!!selectedSkillError}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) clearSelectedSkillError();
|
||||
}}
|
||||
>
|
||||
<DevDialogContent>
|
||||
<DevDialogHeader>
|
||||
<DevDialogTitle>
|
||||
⚠️ {selectedSkillError?.title ?? "技能加载失败"}
|
||||
</DevDialogTitle>
|
||||
</DevDialogHeader>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{selectedSkillError?.message ?? "发生了未知错误,请稍后重试。"}
|
||||
</p>
|
||||
<DevDialogFooter singleColumn>
|
||||
<Button
|
||||
className="w-full bg-[#f9f8fa] hover:bg-[#8E47F0] hover:text-white"
|
||||
variant="ghost"
|
||||
onClick={clearSelectedSkillError}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</DevDialogFooter>
|
||||
</DevDialogContent>
|
||||
</DevDialog>
|
||||
|
||||
{/* MARK: 开发测试:iframe 通信功能测试面板 */}
|
||||
{/* <IframeTestPanel /> */}
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 min-w-[530px] flex flex-col overflow-hidden rounded-lg px-[20px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -31,7 +31,7 @@ export const ArtifactHeader = ({
|
|||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/50 flex items-center justify-between border-b px-4 py-3",
|
||||
"flex items-center justify-between border-b py-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -143,8 +143,7 @@ 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 rounded-[10px] flex-1 overflow-auto">
|
||||
<div className={cn("mb-[207px]! p-4", className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ 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}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip } from "../workspace/tooltip";
|
||||
import type { ChatStatus, FileUIPart } from "ai";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
|
|
@ -70,6 +71,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
// ============================================================================
|
||||
// Provider Context & Types
|
||||
|
|
@ -288,6 +290,7 @@ export function PromptInputAttachment({
|
|||
...props
|
||||
}: PromptInputAttachmentProps) {
|
||||
const attachments = usePromptInputAttachments();
|
||||
const { t } = useI18n();
|
||||
|
||||
const filename = data.filename || "";
|
||||
|
||||
|
|
@ -295,81 +298,112 @@ export function PromptInputAttachment({
|
|||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
const truncateFilename = (name: string, maxLen: number = 10) => {
|
||||
if (name.length <= maxLen) return name;
|
||||
const ext = name.slice(name.lastIndexOf("."));
|
||||
const baseName = name.slice(0, name.lastIndexOf("."));
|
||||
const truncated = baseName.slice(0, maxLen - ext.length - 3);
|
||||
return truncated + "..." + ext;
|
||||
};
|
||||
|
||||
return (
|
||||
<PromptInputHoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none",
|
||||
className,
|
||||
)}
|
||||
key={data.id}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative size-5 shrink-0">
|
||||
<div className="bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0">
|
||||
{isImage ? (
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-5 object-cover"
|
||||
height={20}
|
||||
src={data.url}
|
||||
width={20}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex size-5 items-center justify-center">
|
||||
<PaperclipIcon className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex size-16 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-lg transition-all select-none",
|
||||
isImage ? "p-0" : "bg-gray-100 dark:bg-gray-700",
|
||||
className,
|
||||
)}
|
||||
key={data.id}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
src={data.url}
|
||||
/>
|
||||
{/* 悬浮遮罩层 */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ borderRadius: "10px", background: "rgba(0, 0, 0, 0.60)" }}
|
||||
>
|
||||
{/* 眼睛图标 - 居中 */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M10 4.75C13.3315 4.75 16.4669 6.61444 18.9805 9.88281C19.0335 9.95183 19.0335 10.0482 18.9805 10.1172C16.4669 13.3856 13.3315 15.25 10 15.25C6.66835 15.2499 3.53309 13.3857 1.01953 10.1172C0.966466 10.0482 0.966465 9.95182 1.01953 9.88281C3.53309 6.61435 6.66835 4.75014 10 4.75Z"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M10 7.75C11.2426 7.75 12.25 8.75736 12.25 10C12.25 11.2426 11.2426 12.25 10 12.25C8.75736 12.25 7.75 11.2426 7.75 10C7.75 8.75736 8.75736 7.75 10 7.75Z"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
{/* 删除按钮 - 右上角 */}
|
||||
<button
|
||||
aria-label={t.common.removeAttachment}
|
||||
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-white/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
attachments.remove(data.id);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M0.75 0.75L6.74995 6.74995"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.75 0.75L0.750025 6.74992"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="flex-1 truncate">{attachmentLabel}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<PromptInputHoverCardContent className="w-auto p-2">
|
||||
<div className="w-auto space-y-3">
|
||||
{isImage && (
|
||||
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
|
||||
<img
|
||||
alt={filename || "attachment preview"}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
height={384}
|
||||
src={data.url}
|
||||
width={448}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="min-w-0 flex-1 space-y-1 px-0.5">
|
||||
<h4 className="truncate text-sm leading-none font-semibold">
|
||||
{filename || (isImage ? "Image" : "Attachment")}
|
||||
</h4>
|
||||
{data.mediaType && (
|
||||
<p className="text-muted-foreground truncate font-mono text-xs">
|
||||
{data.mediaType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-1">
|
||||
<PaperclipIcon className="size-6 text-gray-400" />
|
||||
<span className="max-w-full truncate text-center text-[10px] text-gray-500">
|
||||
{truncateFilename(filename)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PromptInputHoverCardContent>
|
||||
</PromptInputHoverCard>
|
||||
{/* 关闭按钮 - 右上角 */}
|
||||
<button
|
||||
aria-label={t.common.removeAttachment}
|
||||
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-white/90 opacity-0 transition-opacity hover:bg-white group-hover:opacity-100 dark:bg-gray-800/90 dark:hover:bg-gray-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
attachments.remove(data.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3 text-gray-600 dark:text-gray-300" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -393,13 +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>
|
||||
);
|
||||
|
|
@ -457,10 +492,13 @@ export type PromptInputProps = Omit<
|
|||
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,
|
||||
|
|
@ -794,7 +832,7 @@ export const PromptInput = ({
|
|||
ref={formRef}
|
||||
{...props}
|
||||
>
|
||||
<InputGroup>{children}</InputGroup>
|
||||
<InputGroup className={inputGroupClassName}>{children}</InputGroup>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
|
@ -1027,32 +1065,63 @@ 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") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
text = "错误";
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
className={cn(className)}
|
||||
size={size}
|
||||
type="submit"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? Icon}
|
||||
</InputGroupButton>
|
||||
<Tooltip content={t.inputBox.sendMessagePrice}>
|
||||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
// 被button{bgc:#fff}覆盖了,只能加"!"
|
||||
className={cn(
|
||||
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
|
||||
// isDisabled
|
||||
// ? "cursor-not-allowed !bg-gray-200 text-gray-400":
|
||||
"!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
type="submit"
|
||||
variant={variant}
|
||||
// disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{/* {children ?? Icon} */}
|
||||
{text}
|
||||
</InputGroupButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1128,8 +1197,6 @@ export const PromptInputSpeechButton = ({
|
|||
null,
|
||||
);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const callbacksRef = useRef({ textareaRef, onTranscriptionChange });
|
||||
callbacksRef.current = { textareaRef, onTranscriptionChange };
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -1162,18 +1229,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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1191,7 +1255,7 @@ export const PromptInputSpeechButton = ({
|
|||
recognitionRef.current.stop();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [textareaRef, onTranscriptionChange]);
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (!recognition) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 overflow-hidden relative flex w-full items-center 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]",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"w-auto h-full 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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -185,15 +185,20 @@ export function ArtifactFileDetail({
|
|||
|
||||
return (
|
||||
// 给滚动遮挡头部定位relative
|
||||
<Artifact className={cn("relative",className)}>
|
||||
<ArtifactHeader>
|
||||
<Artifact
|
||||
className={cn(
|
||||
"bg-background relative overflow-hidden rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ArtifactHeader className="">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{previewable && (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant={null}
|
||||
size="default"
|
||||
className="h-[28px]"
|
||||
className="h-[28px] bg-white"
|
||||
value={viewMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
|
|
@ -481,7 +486,7 @@ export function ArtifactFileDetail({
|
|||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<CodeEditor
|
||||
className="size-full py-[20px] resize-none rounded-none border-none"
|
||||
className="size-full py-[20px] resize-none rounded-none border-none"
|
||||
value={displayContent ?? ""}
|
||||
zoom={zoom}
|
||||
readonly
|
||||
|
|
@ -578,8 +583,8 @@ export const ArtifactZoomSelector = ({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-[28px] items-center gap-1 rounded-[10px] bg-white backdrop-blur-sm",
|
||||
"dark:border-gray-700/50 dark:bg-gray-800/90",
|
||||
"bg-background inline-flex h-[28px] items-center gap-1 rounded-[10px] border border-border backdrop-blur-sm",
|
||||
"dark:border-border dark:bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -590,9 +595,9 @@ export const ArtifactZoomSelector = ({
|
|||
disabled={!canZoomIn}
|
||||
className={cn(
|
||||
"flex h-full w-10 items-center justify-center rounded py-1 transition-colors",
|
||||
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
|
||||
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
|
||||
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground",
|
||||
)}
|
||||
aria-label="放大"
|
||||
>
|
||||
|
|
@ -618,8 +623,8 @@ export const ArtifactZoomSelector = ({
|
|||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[36px] text-center text-xs font-medium text-gray-600",
|
||||
"dark:text-gray-300",
|
||||
"text-foreground min-w-[36px] text-center text-xs font-medium",
|
||||
"dark:text-foreground",
|
||||
)}
|
||||
>
|
||||
{value}%
|
||||
|
|
@ -630,9 +635,9 @@ export const ArtifactZoomSelector = ({
|
|||
disabled={!canZoomOut}
|
||||
className={cn(
|
||||
"flex h-full w-10 items-center justify-center rounded transition-colors",
|
||||
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
|
||||
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
|
||||
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground",
|
||||
)}
|
||||
aria-label="缩小"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -80,12 +80,14 @@ export function ArtifactFileList({
|
|||
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 pl-8 overflow-hidden">
|
||||
<div className=" text-ellipsis whitespace-nowrap text-sm font-normal" 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>
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
// className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import type { Todo } from "@/core/todos";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
QueueItem,
|
||||
QueueItemContent,
|
||||
QueueItemIndicator,
|
||||
QueueList,
|
||||
} from "../ai-elements/queue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
export function DevTodoList({
|
||||
className,
|
||||
todos,
|
||||
trigger,
|
||||
hidden,
|
||||
}: {
|
||||
className?: string;
|
||||
todos: Todo[];
|
||||
trigger: React.ReactNode;
|
||||
hidden: boolean;
|
||||
}) {
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
console.log(todos);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className={cn(
|
||||
"z-[100] rounded-[20px] bg-white p-5 shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
|
||||
className,
|
||||
)}
|
||||
align="start"
|
||||
side="top"
|
||||
>
|
||||
<QueueList className="w-64">
|
||||
{todos.map((todo, i) => (
|
||||
<QueueItem key={i + (todo.content ?? "")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<QueueItemIndicator
|
||||
className={
|
||||
todo.status === "in_progress" ? "bg-primary/70" : ""
|
||||
}
|
||||
completed={todo.status === "completed"}
|
||||
/>
|
||||
<QueueItemContent
|
||||
className={
|
||||
todo.status === "in_progress" ? "text-primary/70" : ""
|
||||
}
|
||||
completed={todo.status === "completed"}
|
||||
>
|
||||
{todo.content}
|
||||
</QueueItemContent>
|
||||
</div>
|
||||
</QueueItem>
|
||||
))}
|
||||
</QueueList>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||
import { 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 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();
|
||||
copyToClipboard(testText);
|
||||
addLog(`copyToClipboard → "${testText.slice(0, 30)}..."`);
|
||||
}
|
||||
|
||||
// 检测是否在 iframe 中
|
||||
const isInIframe = typeof window !== "undefined" && window.self !== window.top;
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
className="fixed bottom-24 left-3 z-[9999] rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white shadow-lg hover:bg-violet-600"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
🧪 测试面板
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="fixed bottom-24 left-3 z-[9999] w-72 rounded-xl border border-violet-200 bg-white/95 shadow-2xl backdrop-blur-sm">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between rounded-t-xl bg-violet-500 px-3 py-2">
|
||||
<span className="text-xs font-bold text-white">🧪 iframe 通信测试</span>
|
||||
<button
|
||||
className="text-white/70 hover:text-white"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-3">
|
||||
{/* 当前状态 */}
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
||||
<div className="mb-1 font-semibold text-gray-500">当前状态</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
<span className="text-gray-400">mode:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono font-bold",
|
||||
isSkillMode ? "text-violet-600" : "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{isSkillMode ? "skill ✅" : "普通"}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-gray-400">selectedSkill:</span>
|
||||
<span className="font-mono text-violet-600">
|
||||
{iframeSkill.selectedSkill
|
||||
? `${iframeSkill.selectedSkill.skill_id} / ${iframeSkill.selectedSkill.title}`
|
||||
: "无"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 1:侧边栏隐藏 */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-gray-500">
|
||||
① 侧边栏隐藏(layout)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
variant="outline"
|
||||
onClick={handleEnterSkillMode}
|
||||
>
|
||||
进入 skill 模式
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
variant="outline"
|
||||
onClick={handleExitSkillMode}
|
||||
>
|
||||
退出 skill 模式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景 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>
|
||||
|
||||
{/* 日志 */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ import {
|
|||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
|
|
@ -81,79 +82,6 @@ import {
|
|||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
const POST_MESSAGE_TYPES = {
|
||||
SELECT_SKILL: "selectSkill",
|
||||
OPEN_SKILL_DIALOG: "openSkillDialog",
|
||||
} as const;
|
||||
|
||||
const RECEIVE_MESSAGE_TYPES = {
|
||||
SELECTED_SKILL: "selectedSkill",
|
||||
} as const;
|
||||
|
||||
type IframeSelectedSkillMessage = {
|
||||
type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL;
|
||||
id: string | number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type IframeSkillData = {
|
||||
skill_id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function sendIframeMessageToParent(message: unknown): void {
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(message, "*");
|
||||
}
|
||||
}
|
||||
|
||||
function useEmbeddedIframeSkill() {
|
||||
const searchParams = useSearchParams();
|
||||
const skillIdFromQuery = searchParams.get("skill_id");
|
||||
const titleFromQuery = searchParams.get("title");
|
||||
const [selectedSkill, setSelectedSkill] = useState<IframeSkillData | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (skillIdFromQuery && titleFromQuery) {
|
||||
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
|
||||
}
|
||||
}, [skillIdFromQuery, titleFromQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||
const { id, title } = event.data as IframeSelectedSkillMessage;
|
||||
setSelectedSkill({ skill_id: String(id), title });
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, []);
|
||||
|
||||
const sendSelectSkill = useCallback((skill_id: string) => {
|
||||
sendIframeMessageToParent({ type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id });
|
||||
}, []);
|
||||
|
||||
const openSkillDialog = useCallback(() => {
|
||||
sendIframeMessageToParent({
|
||||
type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG,
|
||||
openSkillDialog: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSkill = useCallback(() => {
|
||||
setSelectedSkill(null);
|
||||
sendIframeMessageToParent({
|
||||
type: POST_MESSAGE_TYPES.SELECT_SKILL,
|
||||
skill_id: "0",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||||
}
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
disabled,
|
||||
|
|
@ -163,7 +91,6 @@ export function InputBox({
|
|||
extraHeader,
|
||||
isNewThread,
|
||||
hasSubmitted,
|
||||
threadId: threadIdProp,
|
||||
initialValue,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
|
|
@ -182,7 +109,6 @@ export function InputBox({
|
|||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
hasSubmitted?: boolean;
|
||||
threadId?: string;
|
||||
initialValue?: string;
|
||||
onContextChange?: (
|
||||
context: Omit<
|
||||
|
|
@ -197,10 +123,10 @@ export function InputBox({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const iframeSkill = useEmbeddedIframeSkill();
|
||||
const iframeSkill = useIframeSkill();
|
||||
|
||||
const params = useParams();
|
||||
const threadId = threadIdProp ?? params?.thread_id;
|
||||
const threadId = params?.thread_id;
|
||||
const { textInput } = usePromptInputController();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -375,18 +301,16 @@ export function InputBox({
|
|||
|
||||
<PromptInput
|
||||
className={cn(
|
||||
"bg-background w-full rounded-2xl transition-all duration-300 ease-out",
|
||||
"*:data-[slot='input-group']:rounded-[20px] *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:backdrop-blur-sm *:data-[slot='input-group']:shadow-none",
|
||||
"*:data-[slot='input-group']:transition-[height] *:data-[slot='input-group']:duration-300 *:data-[slot='input-group']:ease-out",
|
||||
!isNewThread &&
|
||||
"*:data-[slot='input-group']:h-[200px] *:data-[slot='input-group']:shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
|
||||
hasSubmitted &&
|
||||
"*:data-[slot='input-group']:shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
||||
effectiveIsFocused
|
||||
? "*:data-[slot='input-group']:h-[200px]"
|
||||
: "*:data-[slot='input-group']:h-[80px]",
|
||||
"bg-background w-full rounded-2xl transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
inputGroupClassName={cn(
|
||||
"border-0 rounded-[20px] backdrop-blur-sm",
|
||||
"transition-[height] duration-300 ease-out shadow-none ",
|
||||
!isNewThread && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
|
||||
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
||||
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
globalDrop
|
||||
multiple
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ export function MessageListItem({
|
|||
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
|
||||
|
|
@ -60,7 +63,7 @@ export function MessageListItem({
|
|||
{!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",
|
||||
)}
|
||||
>
|
||||
|
|
@ -327,7 +330,7 @@ function RichFileCard({
|
|||
|
||||
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
|
||||
|
|
@ -389,14 +392,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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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,
|
||||
|
|
@ -33,36 +34,39 @@ export function MessageList({
|
|||
className,
|
||||
threadId,
|
||||
thread,
|
||||
messagesOverride,
|
||||
suppressThreadLoading = false,
|
||||
paddingBottom = 160,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
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;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
if (thread.isThreadLoading && messages.length === 0) {
|
||||
const messages = messagesOverride ?? thread.messages;
|
||||
if (thread.isThreadLoading && !suppressThreadLoading) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
return (
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
|
||||
<ConversationContent className="w-full gap-8 px-[20px]">
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<MessageListItem
|
||||
key={group.id}
|
||||
message={group.messages[0]!}
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
);
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
|
|
@ -168,9 +172,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
|
||||
|
|
|
|||
|
|
@ -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,14 @@ export function ThreadTitle({
|
|||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
thread?.isThreadLoading,
|
||||
thread?.values,
|
||||
]);
|
||||
|
||||
if (!thread.values?.title) {
|
||||
if (threadTitle) {
|
||||
return <FlipDisplay uniqueKey={threadTitle}>{threadTitle}</FlipDisplay>;
|
||||
}
|
||||
if (!thread || !thread.values?.title || !threadId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -41,10 +41,16 @@ export function Welcome({
|
|||
`✨ ${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>
|
||||
<AuroraText
|
||||
className="text-center text-[18px] leading-normal font-normal"
|
||||
style={{
|
||||
color: "var(--color-foreground, #333333)",
|
||||
fontFamily: '"Microsoft YaHei"',
|
||||
}}
|
||||
colors={colors}
|
||||
>
|
||||
{t.welcome.greeting}
|
||||
</AuroraText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -59,13 +65,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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* iframe 与宿主页通信消息类型常量
|
||||
*
|
||||
* 消息格式:{ type: MESSAGE_TYPE, ...其他字段 }
|
||||
* 发送方式:window.parent.postMessage(message, "*")
|
||||
*/
|
||||
|
||||
// 发送给宿主页的消息类型
|
||||
export const POST_MESSAGE_TYPES = {
|
||||
// 全屏切换
|
||||
FULLSCREEN: "fullscreen",
|
||||
// 选择预定义 skill
|
||||
SELECT_SKILL: "selectSkill",
|
||||
// 打开 skill 选择对话框
|
||||
OPEN_SKILL_DIALOG: "openSkillDialog",
|
||||
} as const;
|
||||
|
||||
// 接收来自宿主页的消息类型
|
||||
export const RECEIVE_MESSAGE_TYPES = {
|
||||
// 选中的 skill 数据
|
||||
SELECTED_SKILL: "selectedSkill",
|
||||
} as const;
|
||||
|
||||
// 消息类型
|
||||
export type PostMessageType =
|
||||
(typeof POST_MESSAGE_TYPES)[keyof typeof POST_MESSAGE_TYPES];
|
||||
export type ReceiveMessageType =
|
||||
(typeof RECEIVE_MESSAGE_TYPES)[keyof typeof RECEIVE_MESSAGE_TYPES];
|
||||
|
||||
// 消息数据类型
|
||||
export interface FullscreenMessage {
|
||||
type: typeof POST_MESSAGE_TYPES.FULLSCREEN;
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
export interface SelectSkillMessage {
|
||||
type: typeof POST_MESSAGE_TYPES.SELECT_SKILL;
|
||||
skill_id: string;
|
||||
}
|
||||
|
||||
export interface OpenSkillDialogMessage {
|
||||
type: typeof POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG;
|
||||
openSkillDialog: true;
|
||||
}
|
||||
|
||||
export interface SelectedSkillMessage {
|
||||
type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL;
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// 发送消息的辅助函数
|
||||
export function sendToParent(
|
||||
message: FullscreenMessage | SelectSkillMessage | OpenSkillDialogMessage,
|
||||
): void {
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(message, "*");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useStream, type UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -15,8 +15,9 @@ import type { LocalSettings } from "../settings";
|
|||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { uploadFiles } from "../uploads";
|
||||
import type { UploadTarget } from "../uploads/api";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
import type { AgentThread, AgentThreadContext, AgentThreadState } from "./types";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
|
|
@ -32,6 +33,14 @@ export type ThreadStreamOptions = {
|
|||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
export type LegacyThreadStreamOptions = {
|
||||
isNewThread: boolean;
|
||||
threadId: string | null | undefined;
|
||||
fetchStateHistory?: boolean;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
useSubmitThread?: boolean;
|
||||
};
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
|
|
@ -55,6 +64,64 @@ function getStreamErrorMessage(error: unknown): string {
|
|||
return "Request failed.";
|
||||
}
|
||||
|
||||
export function useThreadStreamLegacy({
|
||||
threadId,
|
||||
isNewThread,
|
||||
fetchStateHistory = true,
|
||||
onFinish,
|
||||
}: LegacyThreadStreamOptions): UseStream<AgentThreadState> {
|
||||
const queryClient = useQueryClient();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const thread = useStream<AgentThreadState>({
|
||||
client: getAPIClient(),
|
||||
assistantId: "lead_agent",
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
reconnectOnMount: true,
|
||||
fetchStateHistory,
|
||||
onCustomEvent(event: unknown) {
|
||||
console.info(event);
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
event.type === "task_running"
|
||||
) {
|
||||
const e = event as {
|
||||
type: "task_running";
|
||||
task_id: string;
|
||||
message: AIMessage;
|
||||
};
|
||||
updateSubtask({ id: e.task_id, latestMessage: e.message });
|
||||
}
|
||||
},
|
||||
onFinish(state) {
|
||||
onFinish?.(state.values);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread>) => {
|
||||
return oldData.map((t) => {
|
||||
if (t.thread_id === threadId) {
|
||||
return {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title: state.values.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
context,
|
||||
|
|
@ -410,6 +477,123 @@ export function useThreadStream({
|
|||
return [mergedThread, sendMessage, isUploading] as const;
|
||||
}
|
||||
|
||||
export function useSubmitThread({
|
||||
threadId,
|
||||
thread,
|
||||
threadContext,
|
||||
isNewThread,
|
||||
createNewSession,
|
||||
uploadTarget,
|
||||
afterSubmit,
|
||||
}: {
|
||||
isNewThread: boolean;
|
||||
createNewSession: boolean;
|
||||
threadId: string | null | undefined;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
threadContext: Omit<AgentThreadContext, "thread_id">;
|
||||
uploadTarget?: UploadTarget;
|
||||
afterSubmit?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
const callback = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
|
||||
const hasFiles = !!(message.files && message.files.length > 0);
|
||||
if (!text && !hasFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (createNewSession && threadId) {
|
||||
try {
|
||||
await apiClient.threads.delete(threadId);
|
||||
} catch {
|
||||
// Ignore delete errors
|
||||
}
|
||||
}
|
||||
|
||||
if (message.files && message.files.length > 0) {
|
||||
try {
|
||||
const filePromises = message.files.map(async (fileUIPart) => {
|
||||
if (fileUIPart.url && fileUIPart.filename) {
|
||||
try {
|
||||
const response = await fetch(fileUIPart.url);
|
||||
const blob = await response.blob();
|
||||
|
||||
return new File([blob], fileUIPart.filename, {
|
||||
type: fileUIPart.mediaType || blob.type,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to fetch file ${fileUIPart.filename}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const files = (await Promise.all(filePromises)).filter(
|
||||
(file): file is File => file !== null,
|
||||
);
|
||||
|
||||
if (files.length > 0 && threadId) {
|
||||
await uploadFiles(threadId, files, { target: uploadTarget });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to upload files:", error);
|
||||
}
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Message[],
|
||||
},
|
||||
{
|
||||
threadId: createNewSession ? threadId! : undefined,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
streamMode: ["values", "messages-tuple", "custom"],
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...threadContext,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
afterSubmit?.();
|
||||
},
|
||||
[
|
||||
thread,
|
||||
isNewThread,
|
||||
createNewSession,
|
||||
threadId,
|
||||
threadContext,
|
||||
uploadTarget,
|
||||
queryClient,
|
||||
apiClient,
|
||||
afterSubmit,
|
||||
],
|
||||
);
|
||||
return callback;
|
||||
}
|
||||
|
||||
export function useThreads(
|
||||
params: Parameters<ThreadsClient["search"]>[0] = {
|
||||
limit: 50,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
import {
|
||||
POST_MESSAGE_TYPES,
|
||||
RECEIVE_MESSAGE_TYPES,
|
||||
sendToParent,
|
||||
type SelectedSkillMessage,
|
||||
} from "@/core/iframe-messages";
|
||||
|
||||
// Skill 数据类型
|
||||
interface SkillData {
|
||||
skill_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Hook 返回类型
|
||||
interface UseIframeSkillReturn {
|
||||
selectedSkill: SkillData | null;
|
||||
sendSelectSkill: (skill_id: string) => void;
|
||||
openSkillDialog: () => void;
|
||||
clearSkill: () => void;
|
||||
}
|
||||
|
||||
export function useIframeSkill(): UseIframeSkillReturn {
|
||||
const searchParams = useSearchParams();
|
||||
const skillIdFromQuery = searchParams.get("skill_id");
|
||||
const titleFromQuery = searchParams.get("title");
|
||||
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
|
||||
|
||||
// 1. 监听 query 参数变化
|
||||
useEffect(() => {
|
||||
if (skillIdFromQuery && titleFromQuery) {
|
||||
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
|
||||
}
|
||||
}, [skillIdFromQuery, titleFromQuery]);
|
||||
|
||||
// 2. 监听宿主页 postMessage
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||
const { id, title } = event.data as SelectedSkillMessage;
|
||||
setSelectedSkill({ skill_id: String(id), title });
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, []);
|
||||
|
||||
// 发送选择预定义 skill
|
||||
const sendSelectSkill = useCallback((skill_id: string) => {
|
||||
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id };
|
||||
console.log("[useIframeSkill] sendSelectSkill:", message);
|
||||
sendToParent(message);
|
||||
}, []);
|
||||
|
||||
// 打开 skill 选择对话框
|
||||
const openSkillDialog = useCallback(() => {
|
||||
const message = {
|
||||
type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG,
|
||||
openSkillDialog: true,
|
||||
} as const;
|
||||
console.log("[useIframeSkill] openSkillDialog:", message);
|
||||
sendToParent(message);
|
||||
}, []);
|
||||
|
||||
// 清除选中并发送 skill_id=0 给主页
|
||||
const clearSkill = useCallback(() => {
|
||||
setSelectedSkill(null);
|
||||
// 发送 skill_id=0 给主页,通知取消选择
|
||||
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" };
|
||||
console.log("[useIframeSkill] clearSkill, sending skill_id=0:", message);
|
||||
sendToParent(message);
|
||||
}, []);
|
||||
|
||||
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
||||
|
||||
/** 宿主页发过来的 selectedSkill 消息结构 */
|
||||
interface SelectedSkillMessage {
|
||||
type: "selectedSkill";
|
||||
id: number | string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** 技能基础数据 */
|
||||
interface SkillData {
|
||||
skill_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** 错误信息状态 */
|
||||
interface SkillError {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UseSelectedSkillListenerOptions {
|
||||
/** 当前会话 thread_id,用于调用 bootstrapRemoteSkill */
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
interface UseSelectedSkillListenerReturn {
|
||||
/** 当前选中的技能数据(用于 UI 展示,如 Badge) */
|
||||
selectedSkill: SkillData | null;
|
||||
/** 当前错误信息,不为 null 时展示 DevDialog */
|
||||
skillError: SkillError | null;
|
||||
/** 清除错误信息(关闭 DevDialog 时调用) */
|
||||
clearSkillError: () => void;
|
||||
/** 是否正在加载(处理 skill 中) */
|
||||
isBootstrapping: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听宿主页通过 postMessage 发送的 selectedSkill 消息或 URL 中的 skill 参数,
|
||||
* 收到后自动调用 bootstrapRemoteSkill 接口:
|
||||
* - 成功:使用 toast 提示
|
||||
* - 失败:返回 skillError 供 DevDialog 显示
|
||||
*/
|
||||
export function useSelectedSkillListener({
|
||||
threadId,
|
||||
}: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn {
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
|
||||
const [skillError, setSkillError] = useState<SkillError | null>(null);
|
||||
const [isBootstrapping, setIsBootstrapping] = useState(false);
|
||||
|
||||
const isFirstLoadRef = useRef(false);
|
||||
const skillBootstrappedKeyRef = useRef<string | null>(null);
|
||||
|
||||
const performBootstrap = useCallback(
|
||||
async (id: number | string, title: string) => {
|
||||
if (!threadId) return;
|
||||
|
||||
const languageTypeRaw =
|
||||
searchParams.get("languageType")?.trim() ??
|
||||
searchParams.get("language_type")?.trim();
|
||||
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
||||
|
||||
const initKey = `${threadId}:${id}:${languageType}`;
|
||||
if (skillBootstrappedKeyRef.current === initKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`,
|
||||
);
|
||||
setIsBootstrapping(true);
|
||||
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
|
||||
|
||||
try {
|
||||
const result = await bootstrapRemoteSkill({
|
||||
thread_id: threadId,
|
||||
content_id: Number(id),
|
||||
language_type: languageType,
|
||||
target_dir: "/mnt/user-data/uploads/skill",
|
||||
clear_target: true,
|
||||
});
|
||||
|
||||
toast.dismiss("skill-bootstrap");
|
||||
|
||||
if (result.success) {
|
||||
skillBootstrappedKeyRef.current = initKey;
|
||||
toast.success(`技能「${title}」加载成功`, {
|
||||
description:
|
||||
result.message || `已创建 ${result.created_files} 个文件`,
|
||||
duration: 4000,
|
||||
});
|
||||
} else {
|
||||
setSkillError({
|
||||
title: `技能「${title}」加载失败`,
|
||||
message: result.message || "未知错误",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.dismiss("skill-bootstrap");
|
||||
const message = err instanceof Error ? err.message : "网络请求失败";
|
||||
setSkillError({ title: `技能「${title}」加载出错`, message });
|
||||
} finally {
|
||||
setIsBootstrapping(false);
|
||||
}
|
||||
},
|
||||
[threadId, searchParams],
|
||||
);
|
||||
|
||||
// 1. URL 初始化集成
|
||||
useEffect(() => {
|
||||
if (!threadId || isFirstLoadRef.current) return;
|
||||
|
||||
const skillIdFromQuery = searchParams.get("skill_id");
|
||||
const titleFromQuery = searchParams.get("title");
|
||||
if (skillIdFromQuery && titleFromQuery) {
|
||||
isFirstLoadRef.current = true;
|
||||
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
|
||||
void performBootstrap(skillIdFromQuery, titleFromQuery);
|
||||
}
|
||||
}, [threadId, searchParams, performBootstrap]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
const data = event.data as SelectedSkillMessage;
|
||||
if (data?.type !== "selectedSkill") return;
|
||||
|
||||
const { id, title } = data;
|
||||
console.log(
|
||||
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
|
||||
data,
|
||||
);
|
||||
|
||||
setSelectedSkill({ skill_id: String(id), title });
|
||||
void performBootstrap(id, title);
|
||||
},
|
||||
[performBootstrap],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleMessage]);
|
||||
|
||||
const clearSkillError = useCallback(() => setSkillError(null), []);
|
||||
|
||||
return { selectedSkill, skillError, clearSkillError, isBootstrapping };
|
||||
}
|
||||
Loading…
Reference in New Issue