feat(ui): 重构聊天页布局并规范化 iframe 通信

- 移除 ResizablePanel 组件,改用自定义 flex 布局实现聊天区与 artifacts 面板
- 调整 artifacts 面板样式,支持全屏模式下的布局切换
- 新增 iframe-messages.ts 统一 postMessage 通信协议,定义 FULLSCREEN、SELECT_SKILL 等消息类型
- 优化 artifacts 工具栏图标为 SVG 内联实现,调整 zoom 默认值为 80%
- 重构 dropdown-selector 组件,支持展开/收起状态指示器
- 修改 layout.tsx 中 geist variable 的 className 拼接方式
- 新增 package.json prettier 格式化命令
This commit is contained in:
肖应宇 2026-03-20 10:09:42 +08:00
parent 6335424aca
commit fb226f85a8
11 changed files with 30 additions and 55 deletions

View File

@ -27,7 +27,6 @@ import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { MessageListSkeleton } from "@/components/workspace/messages/skeleton";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
@ -165,10 +164,6 @@ export default function ChatPage() {
const [hasSubmitted, setHasSubmitted] = useState(false);
const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted;
const suppressNewThreadSubmitUi =
isNewThread && createNewSession && hasSubmitted;
const suppressConversationUi =
suppressExistingThreadPrefetchUi || suppressNewThreadSubmitUi;
useEffect(() => {
const pageTitle = isNewThread
@ -176,7 +171,7 @@ export default function ChatPage() {
: thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title
: t.pages.untitled;
if (thread.isThreadLoading && !suppressConversationUi) {
if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
document.title = `${pageTitle} - ${t.pages.appName}`;
@ -188,21 +183,19 @@ export default function ChatPage() {
t.pages.appName,
thread.values.title,
thread.isThreadLoading,
suppressConversationUi,
suppressExistingThreadPrefetchUi,
]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
if (!suppressConversationUi) {
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]!);
}
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]!);
}
}
}, [
@ -262,7 +255,7 @@ export default function ChatPage() {
<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-[50%]",
artifactsOpen ? "w-full" : "w-[70%]",
)}
>
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
@ -354,7 +347,7 @@ export default function ChatPage() {
<MessageList
className={cn(
"size-full",
(!isNewThread || hasSubmitted) && "pt-10",
(!isNewThread || hasSubmitted) && "pt-[20px]",
)}
threadId={threadId}
thread={thread}

View File

@ -141,6 +141,6 @@ export const ArtifactContent = ({
...props
}: ArtifactContentProps) => (
<div className="min-h-0 flex-1 overflow-auto">
<div className={cn("mb-[208px] p-4", className)} {...props} />
<div className={cn("mb-[150px] p-4", className)} {...props} />
</div>
);

View File

@ -11,7 +11,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
className={cn("relative flex-1 overflow-y-hidden mt-[60px]", className)}
initial="smooth"
resize="smooth"
role="log"

View File

@ -29,7 +29,7 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
className={cn(
"group flex w-full flex-col gap-2 rounded-[10px] p-[20px]",
from === "user"
? "is-user ml-auto justify-end"
? "is-user px-0 ml-auto justify-end"
: "is-assistant bg-[#ffffff]",
className,
)}

View File

@ -7,6 +7,7 @@ interface AuroraTextProps {
className?: string;
colors?: string[];
speed?: number;
style?: React.CSSProperties;
}
export const AuroraText = memo(
@ -15,6 +16,7 @@ export const AuroraText = memo(
className = "",
colors = ["#FF0080", "#7928CA", "#0070F3", "#38bdf8"],
speed = 1,
style,
}: AuroraTextProps) => {
const gradientStyle = {
backgroundImage: `linear-gradient(135deg, ${colors.join(", ")}, ${
@ -26,7 +28,7 @@ export const AuroraText = memo(
};
return (
<span className={`relative inline-block ${className}`}>
<span className={`relative inline-block ${className}`} style={style}>
<span className="sr-only">{children}</span>
<span
className="animate-aurora relative bg-size-[200%_auto] bg-clip-text text-transparent"

View File

@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group dark:bg-background/80 relative flex w-full max-w-[720px] items-center overflow-hidden rounded-md bg-[#FBFAFC] shadow-[0_0_20px_0_rgba(0,0,0,0.10)] transition-[color,box-shadow] outline-none",
"group/input-group dark:bg-background/80 relative flex w-full max-w-[720px] items-center overflow-hidden rounded-md bg-[#FBFAFC] transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.

View File

@ -305,7 +305,7 @@ export function InputBox({
)}
inputGroupClassName={cn(
"border-0 rounded-[20px] backdrop-blur-sm",
"transition-[height] duration-300 ease-out",
"transition-[height] duration-300 ease-out shadow-none ",
!isNewThread && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
)}

View File

@ -57,7 +57,7 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="w-full gap-8 px-[20px] pt-12">
<ConversationContent className="w-full gap-8 px-[20px]">
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return (

View File

@ -41,7 +41,14 @@ export function Welcome({
`${t.welcome.createYourOwnSkill}`
) : (
<div className="flex items-center gap-2">
<AuroraText className="text-[18px] text-[#150033]" colors={colors}>
<AuroraText
className="text-center font-normal text-[18px] leading-normal"
style={{
color: "var(--color-150033, #150033)",
fontFamily: '"Microsoft YaHei"',
}}
colors={colors}
>
{t.welcome.greeting}
</AuroraText>
</div>

View File

@ -49,7 +49,7 @@ export const zhCN: Translations = {
// Welcome
welcome: {
greeting: "使用Skill",
greeting: "使用 Skill",
description:
"欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。",

View File

@ -18,29 +18,6 @@ import type {
AgentThreadState,
} from "./types";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForThreadStateToBeReadable(
apiClient: ReturnType<typeof getAPIClient>,
threadId: string,
timeoutMs = 3000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const state = await apiClient.threads.getState<AgentThreadState>(threadId);
if ((state.values.messages?.length ?? 0) > 0) {
return;
}
} catch {
// Ignore transient 404 / not-ready errors while the new thread is being persisted.
}
await sleep(100);
}
}
export function useThreadStream({
threadId,
isNewThread,
@ -212,10 +189,6 @@ export function useSubmitThread({
},
);
if (createNewSession && isNewThread && threadId) {
await waitForThreadStateToBeReadable(apiClient, threadId);
}
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.();
},