feat(ui): 详细对齐第一版

This commit is contained in:
肖应宇 2026-03-30 15:16:08 +08:00
parent a721091476
commit e1cd6fc3ca
29 changed files with 1734 additions and 361 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>
);
}

View File

@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-lg",
"bg-background 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>
)

View File

@ -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}

View File

@ -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) {

View File

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

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl py-6",
className
)}
{...props}

View File

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

View File

@ -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]",

View File

@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}

View File

@ -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,
)}

View File

@ -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="缩小"
>

View File

@ -18,7 +18,7 @@ import {
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { cn, truncateMiddle } from "@/lib/utils";
import { useArtifacts } from "./context";
@ -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>

View File

@ -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}
/>

View File

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

View File

@ -0,0 +1,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=skillURL 已更新");
}
function handleExitSkillMode() {
router.push(`?`);
addLog("退出 skill 模式");
}
function handleSendSelectSkill() {
iframeSkill.sendSelectSkill("skill_001");
addLog("postMessage → selectSkill (skill_id=skill_001)");
}
function handleOpenSkillDialog() {
iframeSkill.openSkillDialog();
addLog("postMessage → openSkillDialog");
}
function handleClearSkill() {
iframeSkill.clearSkill();
addLog("clearSkill 已调用postMessage → skill_id=0");
}
function handleTestClipboardCopy() {
const testText = "测试复制内容 - " + new Date().toISOString();
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>
{/* 场景 2skill 选择通信 */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
postMessage 宿
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
variant="ghost"
onClick={handleSendSelectSkill}
>
sendSelectSkill (skill_001)
</Button>
<Button
size="sm"
className="w-full bg-violet-50 text-xs text-violet-700 hover:bg-violet-100"
variant="ghost"
onClick={handleOpenSkillDialog}
>
openSkillDialog
</Button>
<Button
size="sm"
className="w-full bg-red-50 text-xs text-red-600 hover:bg-red-100"
variant="ghost"
onClick={handleClearSkill}
>
clearSkill ( skill_id=0)
</Button>
</div>
</div>
{/* 场景 3接收宿主页 selectedSkill */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
宿 selectedSkill
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-green-50 text-xs text-green-700 hover:bg-green-100"
variant="ghost"
onClick={() => {
window.postMessage(
{ type: "selectedSkill", id: 5, title: "文档处理" },
"*",
);
addLog(
"模拟宿主页 → selectedSkill { id: 5, title: '文档处理' }",
);
}}
>
selectedSkill
</Button>
<Button
size="sm"
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
variant="ghost"
onClick={() => {
window.postMessage(
{ type: "selectedSkill", id: 999999, title: "不存在的技能" },
"*",
);
addLog(
"模拟宿主页 → selectedSkill { id: 999999, title: '不存在的技能' }",
);
}}
>
selectedSkill/
</Button>
</div>
</div>
{/* 场景 4剪贴板复制iframe 通信) */}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500">
iframe
</span>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium",
isInIframe
? "bg-violet-100 text-violet-700"
: "bg-gray-100 text-gray-500",
)}
>
{isInIframe ? "iframe 模式" : "独立页面"}
</span>
</div>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full bg-blue-50 text-xs text-blue-700 hover:bg-blue-100"
variant="ghost"
onClick={handleTestClipboardCopy}
>
📋
</Button>
<div className="rounded bg-gray-100 px-2 py-1.5 text-[10px] text-gray-600">
{isInIframe
? "将通过 postMessage 请求父页面复制"
: "将直接调用 navigator.clipboard"}
</div>
</div>
</div>
{/* 日志 */}
{log.length > 0 && (
<div className="rounded-lg bg-gray-900 p-2">
<div className="mb-1 text-[10px] font-semibold text-gray-400">
</div>
{log.map((l, i) => (
<div
key={i}
className="truncate font-mono text-[10px] text-green-400"
>
{l}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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>
);

View File

@ -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, "*");
}
}

View File

@ -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,

View File

@ -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 };
}

View File

@ -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 };
}