Compare commits

...

5 Commits

Author SHA1 Message Date
肖应宇 fb226f85a8 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 格式化命令
2026-03-20 10:09:42 +08:00
肖应宇 6335424aca Merge remote-tracking branch 'origin/feat/originui' into feat/originui 2026-03-20 09:22:19 +08:00
肖应宇 4df604d491 style: prettier 2026-03-19 17:33:47 +08:00
肖应宇 cb0ebf41bb feat(ui): 重构聊天页布局并规范化iframe 通信 2026-03-19 17:32:19 +08:00
肖应宇 3a7940654c debug: 修复时序问题导致 的获取历史记录为[] 2026-03-19 11:39:50 +08:00
23 changed files with 884 additions and 422 deletions

View File

@ -0,0 +1,91 @@
# DeerFlow Local Production Test
# Usage: docker compose -f docker-compose-local-prod.yaml up -d
#
# Prerequisites:
# 1. Build images first:
# docker build -f frontend/Dockerfile.prod -t deerflow-frontend:local .
# docker build -f backend/Dockerfile -t deerflow-backend:local .
#
# Services:
# - nginx: Reverse proxy (port 2026)
# - frontend: Frontend Next.js (production build)
# - gateway: Backend Gateway API
# - langgraph: LangGraph server
name: deerflow2-local
services:
nginx:
image: nginx:alpine
container_name: deer-flow-nginx
ports:
- "2026:2026"
volumes:
- ./nginx/nginx-local-prod.conf:/etc/nginx/nginx.conf:ro
depends_on:
- frontend
- gateway
- langgraph
networks:
- deer-flow-local
restart: unless-stopped
frontend:
image: deerflow-frontend:local
container_name: deer-flow-frontend
environment:
- NODE_ENV=production
networks:
- deer-flow-local
restart: unless-stopped
gateway:
image: deerflow-backend:local
container_name: deer-flow-gateway
command: sh -c "cd backend && uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001"
volumes:
- ../config.yaml:/app/config.yaml
- ../extensions_config.json:/app/extensions_config.json
- ../skills:/app/skills
- ../logs:/app/logs
- ../backend/.deer-flow:/app/backend/.deer-flow
- ~/.cache/uv:/root/.cache/uv
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CI=true
- DOCKER_HOST=unix:///var/run/docker.sock
env_file:
- ../.env
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- deer-flow-local
restart: unless-stopped
langgraph:
image: deerflow-backend:local
container_name: deer-flow-langgraph
command: sh -c "cd backend && exec uv run langgraph dev --no-browser --no-reload --allow-blocking --host 0.0.0.0 --port 2024"
volumes:
- ../backend/.langgraph_api:/app/backend/.langgraph_api
- ../config.yaml:/app/config.yaml
- ../extensions_config.json:/app/extensions_config.json
- ../skills:/app/skills
- ../logs:/app/logs
- ../backend/.deer-flow:/app/backend/.deer-flow
- ~/.cache/uv:/root/.cache/uv
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CI=true
- DOCKER_HOST=unix:///var/run/docker.sock
env_file:
- ../.env
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- deer-flow-local
restart: unless-stopped
networks:
deer-flow-local:
driver: bridge

View File

@ -0,0 +1,207 @@
events {
worker_connections 1024;
}
pid /tmp/nginx.pid;
http {
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Logging
access_log /dev/stdout;
error_log /dev/stderr;
# Docker internal DNS (for resolving service names)
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream servers (using Docker service names)
upstream gateway {
server gateway:8001;
}
upstream langgraph {
server langgraph:2024;
}
upstream frontend {
server frontend:3000;
}
# ── Main server (path-based routing) ─────────────────────────────────
server {
listen 2026 default_server;
listen [::]:2026 default_server;
server_name _;
# Hide CORS headers from upstream to prevent duplicates
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
# CORS headers for all responses (nginx handles CORS centrally)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' '*' always;
# Handle OPTIONS requests (CORS preflight)
if ($request_method = 'OPTIONS') {
return 204;
}
# LangGraph API routes
# Rewrites /api/langgraph/* to /* before proxying
location /api/langgraph/ {
rewrite ^/api/langgraph/(.*) /$1 break;
proxy_pass http://langgraph;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
# SSE/Streaming support
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
# Timeouts for long-running requests
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Chunked transfer encoding
chunked_transfer_encoding on;
}
# Custom API: Models endpoint
location /api/models {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Custom API: Memory endpoint
location /api/memory {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Custom API: MCP configuration endpoint
location /api/mcp {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Custom API: Skills configuration endpoint
location /api/skills {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Custom API: Artifacts endpoint
location ~ ^/api/threads/[^/]+/artifacts {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Custom API: Uploads endpoint
location ~ ^/api/threads/[^/]+/uploads {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Large file upload support
client_max_body_size 100M;
proxy_request_buffering off;
}
# API Documentation: Swagger UI
location /docs {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API Documentation: ReDoc
location /redoc {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API Documentation: OpenAPI Schema
location /openapi.json {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check endpoint (gateway)
location /health {
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# All other requests go to frontend
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
}
}

View File

@ -8,6 +8,8 @@
"build": "next build",
"check": "next lint && tsc --noEmit",
"dev": "next dev --turbo",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"preview": "next build && next start",

View File

@ -25,7 +25,7 @@ export default async function RootLayout({
return (
<html
lang={locale}
className={geist.variable}
className={geist.variable + ""}
suppressContentEditableWarning
suppressHydrationWarning
>

View File

@ -17,11 +17,6 @@ import {
DevDialogHeader,
DevDialogTitle,
} from "@/components/ui/dev-dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import {
ArtifactFileDetail,
@ -32,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";
@ -131,6 +125,10 @@ export default function ChatPage() {
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[state.messages.length - 1];
@ -166,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
@ -177,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}`;
@ -189,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]!);
}
}
}, [
@ -237,7 +229,7 @@ export default function ChatPage() {
subagent_enabled: settings.context.mode === "ultra",
},
afterSubmit() {
router.push(pathOfThread(threadId!));
// 导航已在 onFinish 中处理,确保 stream 完成后再导航
},
});
const handleSubmit = useCallback(
@ -260,290 +252,300 @@ export default function ChatPage() {
return (
<ThreadContext.Provider value={{ threadId, thread }}>
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
className="relative overflow-hidden rounded-[20px]"
defaultSize={artifactPanelOpen ? 46 : 100}
minSize={artifactPanelOpen ? 30 : 100}
>
<div className="relative flex size-full min-h-0 justify-between rounded-[20px]">
<header
className={cn(
"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 ? "hidden" : "",
)}
>
<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"
<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(
"relative overflow-hidden rounded-t-[20px] transition-all duration-300 ease-in-out",
artifactPanelOpen ? "w-[50%]" : "w-full",
fullscreen && "hidden",
)}
>
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
<header
className={cn(
"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="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)}
>
<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>
<div className="flex items-center justify-center overflow-hidden text-sm font-medium">
{title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={title} />
)}
</div>
<div className="flex items-center justify-end gap-2 overflow-hidden">
<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]"
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<ListTodoIcon className="size-4" /> To-dos
</Button>
}
/>
<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>
<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">
<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>
{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>
</header>
<main
className={cn(
"flex min-h-0 max-w-full grow flex-col rounded-[20px]",
isNewThread ? "bg-white" : "bg-background",
)}
>
<div className="flex size-full justify-center">
{suppressConversationUi ? (
<MessageListSkeleton />
) : (
>
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isNewThread && "pt-10")}
className={cn(
"size-full",
(!isNewThread || hasSubmitted) && "pt-[20px]",
)}
threadId={threadId}
thread={thread}
suppressThreadLoading={suppressExistingThreadPrefetchUi}
messagesOverride={
!thread.isLoading && finalState?.messages
? (finalState.messages as Message[])
: undefined
suppressExistingThreadPrefetchUi
? []
: !thread.isLoading && finalState?.messages
? (finalState.messages as Message[])
: undefined
}
paddingBottom={todoListCollapsed ? 160 : 280}
/>
)}
</div>
</main>
</div>
</main>
</div>
</div>
</ResizablePanel>
<ResizableHandle
<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-[70%]"
: "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>
{/* Fixed 底部居中输入框容器 */}
<div
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
"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" : "",
)}
/>
<ResizablePanel
className={cn(
"bg-background ml-[20px] rounded-[20px] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
defaultSize={artifactPanelOpen ? 64 : 0}
minSize={0}
maxSize={artifactPanelOpen ? undefined : 0}
>
<div
className={cn(
"h-full w-full transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
"pointer-events-auto relative w-full max-w-[720px]",
isNewThread && !hasSubmitted && "-translate-y-[calc(50vh-96px)]",
)}
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : (
<div className="relative flex size-full justify-center px-[20px]">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
<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>
{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 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">
{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>
)}
}
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>
</ResizablePanel>
</ResizablePanelGroup>
{/* 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 ? "right-[50%]" : "",
)}
>
<div
className={cn(
"pointer-events-auto relative w-full max-w-[720px]",
isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]",
)}
>
<InputBox
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
isNewThread={isNewThread}
autoFocus={isNewThread}
status={
suppressExistingThreadPrefetchUi
? "ready"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
<div className="flex flex-col gap-4">
{isNewThread && <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>
{/* 退出确认对话框 */}
<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={() => {
setShowExitDialog(false);
router.push("/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>
{/* 退出确认对话框 */}
<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={() => {
setShowExitDialog(false);
router.push("/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 /> */}
</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-[20px] px-[20px] pt-[15px]",
"bg-background flex flex-col overflow-hidden rounded-t-[20px] px-[20px] pt-[15px]",
className,
)}
{...props}
@ -30,10 +30,7 @@ export const ArtifactHeader = ({
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
"mb-[20px] grid grid-cols-3 items-center justify-between",
className,
)}
className={cn("mb-[10px] flex items-center justify-between", className)}
{...props}
/>
);
@ -144,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

@ -150,7 +150,7 @@ export const ChainOfThoughtStep = memo(
{isValidElement(Icon) ? (
Icon
) : (
<Icon className="size-4 stroke-[1.5px] stroke-[#333333] text-[#333333]" />
<Icon className="size-4 stroke-[#333333] stroke-[1.5px] text-[#333333]" />
)}
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</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

@ -62,7 +62,7 @@ export const Suggestion = ({
<Button
className={cn(
"cursor-pointer rounded-full px-[20px] py-[15px] text-xs font-normal",
"bg-[#F9F8FA] text-[#666666] border-none",
"border-none bg-[#F9F8FA] text-[#666666]",
"hover:bg-[#EAE9EB] hover:text-[#150033]",
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

@ -1,3 +1,5 @@
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@ -19,6 +21,46 @@ interface DropdownSelectorProps<T extends string> {
contentClassName?: string;
}
function ChevronDownIcon() {
return (
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.75 0.75L4.75 4.75L8.75 0.75"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronUpIcon() {
return (
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.75 4.75L4.75 0.75L8.75 4.75"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DropdownSelector<T extends string>({
value,
options,
@ -27,16 +69,20 @@ export function DropdownSelector<T extends string>({
contentClassName,
}: DropdownSelectorProps<T>) {
const selectedOption = options.find((opt) => opt.value === value);
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownMenu>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger
className={
triggerClassName ??
"border-none bg-transparent shadow-none select-none focus:outline-none"
}
>
{selectedOption?.label ?? value}
<span className="flex items-center gap-1">
{selectedOption?.label ?? value}
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent className={contentClassName}>
<DropdownMenuRadioGroup

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

@ -69,8 +69,8 @@ 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",
"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",
"h-full w-[50px] min-w-0 shrink-0 cursor-pointer bg-white px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:data-[variant=outline] data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md",
className,
)}
{...props}

View File

@ -43,6 +43,7 @@ import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
@ -64,8 +65,8 @@ export function ArtifactFileDetail({
threadId: string;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const [fullscreen, setFullscreen] = useState(false);
const { artifacts, setOpen, select, fullscreen, setFullscreen } =
useArtifacts();
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
@ -111,33 +112,17 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(100);
const [zoom, setZoom] = useState(80);
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.error("无法进入全屏模式:", err);
});
setFullscreen(true);
} else {
document.exitFullscreen().catch((err) => {
console.error("无法退出全屏模式:", err);
});
setFullscreen(false);
}
}, [setFullscreen]);
// 监听全屏变化
useEffect(() => {
const handleFullscreenChange = () => {
setFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, [setFullscreen]);
const newFullscreen = !fullscreen;
setFullscreen(newFullscreen);
sendToParent({
type: POST_MESSAGE_TYPES.FULLSCREEN,
fullscreen: newFullscreen,
});
}, [fullscreen, setFullscreen]);
useEffect(() => {
if (previewable) {
@ -175,8 +160,9 @@ export function ArtifactFileDetail({
{previewable && (
<ToggleGroup
type="single"
variant="outline"
size="sm"
variant={null}
size="default"
className="h-[28px]"
value={viewMode}
onValueChange={(value) => {
if (value) {
@ -185,13 +171,52 @@ export function ArtifactFileDetail({
}}
>
<ToggleGroupItem value="code">
<Code2Icon />
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 6L2 9L5 12"
stroke="#150033"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M11 3L7 15"
stroke="#150033"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M13 6L16 9L13 12"
stroke="#150033"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</ToggleGroupItem>
<ToggleGroupItem value="preview">
<EyeIcon />
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
>
<path
d="M8 0.5C10.4943 0.5 12.8473 1.84466 14.792 4.21973C15.1644 4.67466 15.1644 5.32534 14.792 5.78027C12.8473 8.15534 10.4943 9.5 8 9.5C5.50561 9.49989 3.15269 8.15543 1.20801 5.78027C0.835561 5.32534 0.835562 4.67466 1.20801 4.21973C3.15269 1.84457 5.50561 0.500106 8 0.5Z"
stroke="#666666"
/>
<circle cx="8" cy="5" r="1.5" stroke="#666666" />
</svg>
</ToggleGroupItem>
</ToggleGroup>
)}
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
</div>
<div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle>
@ -207,8 +232,6 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
<div className="flex items-center justify-end overflow-hidden">
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
<ArtifactActions>
{isCodeFile && (
<ArtifactAction
@ -340,25 +363,27 @@ export function ArtifactFileDetail({
</svg>
)}
</ArtifactAction>
<ArtifactAction
label={t.common.close}
onClick={() => setOpen(false)}
tooltip={t.common.close}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{!fullscreen && (
<ArtifactAction
label={t.common.close}
onClick={() => setOpen(false)}
tooltip={t.common.close}
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
stroke-linecap="round"
/>
</svg>
</ArtifactAction>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
stroke-linecap="round"
/>
</svg>
</ArtifactAction>
)}
</ArtifactActions>
</div>
</ArtifactHeader>
@ -471,8 +496,7 @@ export const ArtifactZoomSelector = ({
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-[10px] bg-white px-2 py-1 backdrop-blur-sm",
"border border-gray-200/50",
"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",
className,
)}
@ -483,18 +507,36 @@ export const ArtifactZoomSelector = ({
onClick={handleZoomIn}
disabled={!canZoomIn}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"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",
"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",
)}
aria-label="放大"
>
<ZoomIn className="h-3.5 w-3.5" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="#666666"
/>
<path
d="M5.33325 7.5H9.7777M7.55547 5V10"
stroke="#666666"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<span
className={cn(
"min-w-[42px] text-center text-xs font-medium text-gray-600",
"min-w-[36px] text-center text-xs font-medium text-gray-600",
"dark:text-gray-300",
)}
>
@ -505,14 +547,32 @@ export const ArtifactZoomSelector = ({
onClick={handleZoomOut}
disabled={!canZoomOut}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"flex h-full w-10 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"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",
)}
aria-label="缩小"
>
<ZoomOut className="h-3.5 w-3.5" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="#666666"
/>
<path
d="M4.99927 7.5H9.99927"
stroke="#666666"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
);

View File

@ -90,6 +90,7 @@ export function InputBox({
context,
extraHeader,
isNewThread,
hasSubmitted,
initialValue,
onContextChange,
onSubmit,
@ -107,6 +108,7 @@ export function InputBox({
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
hasSubmitted?: boolean;
initialValue?: string;
onContextChange?: (
context: Omit<
@ -139,8 +141,9 @@ export function InputBox({
);
const [isFocused, setIsFocused] = useState(false);
// isNewThread 时禁用收缩,始终保持展开
const effectiveIsFocused = (isNewThread ?? false) || isFocused;
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused =
((isNewThread ?? false) && !hasSubmitted) || isFocused;
// 点击外部区域时收起输入框
useEffect(() => {
@ -302,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]",
)}
@ -376,7 +379,7 @@ export function InputBox({
/>
</PromptInput>
{isNewThread && searchParams.get("mode") !== "skill" && (
{isNewThread && !hasSubmitted && searchParams.get("mode") !== "skill" && (
<SuggestionListContainer
sendSelectSkill={iframeSkill.sendSelectSkill}
/>

View File

@ -57,7 +57,7 @@ export function MessageList({
<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 (

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图片、视频、播客及网页等几乎可以做任何事情。",
@ -104,36 +104,42 @@ export const zhCN: Translations = {
followupConfirmReplace: "替换并发送",
suggestions: [
{
suggestion: "论文写作",
suggestion: "自媒体文案",
prompt:
"撰写一篇关于[主题]的学术论文,包含摘要、引言、正文和参考文献。",
"为[主题/产品]撰写吸引人的自媒体文案,包括标题、正文和话题标签。",
icon: PenLineIcon,
skill_id: "1",
skill_id: "432",
},
{
suggestion: "报告生成",
prompt: "深入分析[主题],生成一份结构清晰的调研报告。",
icon: MicroscopeIcon,
skill_id: "2",
suggestion: "需求文档",
prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。",
icon: CompassIcon,
skill_id: "521",
},
{
suggestion: "策划文案",
prompt: "为[项目/活动]撰写一份完整的策划方案和宣传文案。",
icon: ShapesIcon,
skill_id: "3",
suggestion: "使用指南",
prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。",
icon: GraduationCapIcon,
skill_id: "410",
},
{
suggestion: "PPT生成",
prompt: "生成一个关于[主题]的PPT演示文稿大纲和内容。",
icon: GraduationCapIcon,
skill_id: "4",
skill_id: "180",
},
{
suggestion: "文档处理",
prompt: "对[文档]进行阅读、总结、翻译或格式转换等处理。",
icon: CompassIcon,
suggestion: "Excel数据分析",
prompt: "对[Excel文件/数据]进行分析,生成数据洞察和可视化建议。",
icon: MicroscopeIcon,
skill_id: "5",
},
{
suggestion: "市场调研",
prompt: "针对[行业/产品]进行市场调研,分析市场规模、竞品和趋势。",
icon: ShapesIcon,
skill_id: "31",
},
],
suggestionsCreate: [
{

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

@ -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?.();
},

View File

@ -1,11 +1,12 @@
import { useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
// 消息类型常量
const MESSAGE_TYPES = {
SELECT_SKILL: "selectSkill",
OPEN_SKILL_DIALOG: "openSkillDialog",
} as const;
import {
POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES,
sendToParent,
type SelectedSkillMessage,
} from "@/core/iframe-messages";
// Skill 数据类型
interface SkillData {
@ -38,8 +39,8 @@ export function useIframeSkill(): UseIframeSkillReturn {
// 2. 监听宿主页 postMessage
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "selectedSkill") {
const { id, title } = event.data;
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
const { id, title } = event.data as SelectedSkillMessage;
setSelectedSkill({ skill_id: String(id), title });
}
};
@ -49,28 +50,28 @@ export function useIframeSkill(): UseIframeSkillReturn {
// 发送选择预定义 skill
const sendSelectSkill = useCallback((skill_id: string) => {
const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id };
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id };
console.log("[useIframeSkill] sendSelectSkill:", message);
window.parent.postMessage(message, "*");
sendToParent(message);
}, []);
// 打开 skill 选择对话框
const openSkillDialog = useCallback(() => {
const message = {
type: MESSAGE_TYPES.OPEN_SKILL_DIALOG,
type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG,
openSkillDialog: true,
};
} as const;
console.log("[useIframeSkill] openSkillDialog:", message);
window.parent.postMessage(message, "*");
sendToParent(message);
}, []);
// 清除选中并发送 skill_id=0 给主页
const clearSkill = useCallback(() => {
setSelectedSkill(null);
// 发送 skill_id=0 给主页,通知取消选择
const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" };
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" };
console.log("[useIframeSkill] clearSkill, sending skill_id=0:", message);
window.parent.postMessage(message, "*");
sendToParent(message);
}, []);
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };

View File

@ -70,7 +70,9 @@ export function useSelectedSkillListener({
return;
}
console.log(`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`);
console.log(
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`,
);
setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
@ -88,7 +90,8 @@ export function useSelectedSkillListener({
if (result.success) {
skillBootstrappedKeyRef.current = initKey;
toast.success(`技能「${title}」加载成功`, {
description: result.message || `已创建 ${result.created_files} 个文件`,
description:
result.message || `已创建 ${result.created_files} 个文件`,
duration: 4000,
});
} else {
@ -127,7 +130,10 @@ export function useSelectedSkillListener({
if (data?.type !== "selectedSkill") return;
const { id, title } = data;
console.log("[useSelectedSkillListener] 收到 postMessage selectedSkill:", data);
console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
data,
);
setSelectedSkill({ skill_id: String(id), title });
void performBootstrap(id, title);