Compare commits

..

No commits in common. "fb226f85a8b6ff6b15e7efd480c03a4cd3a6412b" and "f67aa274344673fd753b159c4429c2fdf8a47979" have entirely different histories.

23 changed files with 423 additions and 885 deletions

View File

@ -1,91 +0,0 @@
# 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

@ -1,207 +0,0 @@
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,8 +8,6 @@
"build": "next build", "build": "next build",
"check": "next lint && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev --turbo", "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": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix", "lint:fix": "eslint . --ext .ts,.tsx --fix",
"preview": "next build && next start", "preview": "next build && next start",

View File

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

View File

@ -17,6 +17,11 @@ import {
DevDialogHeader, DevDialogHeader,
DevDialogTitle, DevDialogTitle,
} from "@/components/ui/dev-dialog"; } from "@/components/ui/dev-dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { import {
ArtifactFileDetail, ArtifactFileDetail,
@ -27,6 +32,7 @@ import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel"; import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { MessageListSkeleton } from "@/components/workspace/messages/skeleton";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
@ -125,10 +131,6 @@ export default function ChatPage() {
fetchStateHistory: true, fetchStateHistory: true,
onFinish: (state) => { onFinish: (state) => {
setFinalState(state); setFinalState(state);
// 新对话完成后导航到对话页面
if (isNewThread && threadId) {
router.push(pathOfThread(threadId));
}
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished"; let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1]; const lastMessage = state.messages[state.messages.length - 1];
@ -164,6 +166,10 @@ export default function ChatPage() {
const [hasSubmitted, setHasSubmitted] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false);
const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted; const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted;
const suppressNewThreadSubmitUi =
isNewThread && createNewSession && hasSubmitted;
const suppressConversationUi =
suppressExistingThreadPrefetchUi || suppressNewThreadSubmitUi;
useEffect(() => { useEffect(() => {
const pageTitle = isNewThread const pageTitle = isNewThread
@ -171,7 +177,7 @@ export default function ChatPage() {
: thread.values?.title && thread.values.title !== "Untitled" : thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title ? thread.values.title
: t.pages.untitled; : t.pages.untitled;
if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) { if (thread.isThreadLoading && !suppressConversationUi) {
document.title = `Loading... - ${t.pages.appName}`; document.title = `Loading... - ${t.pages.appName}`;
} else { } else {
document.title = `${pageTitle} - ${t.pages.appName}`; document.title = `${pageTitle} - ${t.pages.appName}`;
@ -183,19 +189,21 @@ export default function ChatPage() {
t.pages.appName, t.pages.appName,
thread.values.title, thread.values.title,
thread.isThreadLoading, thread.isThreadLoading,
suppressExistingThreadPrefetchUi, suppressConversationUi,
]); ]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => { useEffect(() => {
setArtifacts(thread.values.artifacts); if (!suppressConversationUi) {
if ( setArtifacts(thread.values.artifacts);
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && if (
autoSelectFirstArtifact env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
) { autoSelectFirstArtifact
if (thread?.values?.artifacts?.length > 0) { ) {
setAutoSelectFirstArtifact(false); if (thread?.values?.artifacts?.length > 0) {
selectArtifact(thread.values.artifacts[0]!); setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
} }
} }
}, [ }, [
@ -229,7 +237,7 @@ export default function ChatPage() {
subagent_enabled: settings.context.mode === "ultra", subagent_enabled: settings.context.mode === "ultra",
}, },
afterSubmit() { afterSubmit() {
// 导航已在 onFinish 中处理,确保 stream 完成后再导航 router.push(pathOfThread(threadId!));
}, },
}); });
const handleSubmit = useCallback( const handleSubmit = useCallback(
@ -252,300 +260,290 @@ export default function ChatPage() {
return ( return (
<ThreadContext.Provider value={{ threadId, thread }}> <ThreadContext.Provider value={{ threadId, thread }}>
<div <ResizablePanelGroup orientation="horizontal">
className={cn( <ResizablePanel
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out", className="relative overflow-hidden rounded-[20px]"
artifactsOpen ? "w-full" : "w-[70%]", defaultSize={artifactPanelOpen ? 46 : 100}
)} minSize={artifactPanelOpen ? 30 : 100}
> >
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]"> <div className="relative flex size-full min-h-0 justify-between rounded-[20px]">
<div <header
className={cn( className={cn(
"relative overflow-hidden rounded-t-[20px] transition-all duration-300 ease-in-out", "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",
artifactPanelOpen ? "w-[50%]" : "w-full", isNewThread ? "hidden" : "",
fullscreen && "hidden", )}
)} >
> <div className="flex items-center justify-start overflow-hidden text-sm font-medium">
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]"> <Button
<header size="sm"
className={cn( variant="ghost"
"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", className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
isNewThread && !hasSubmitted ? "hidden" : "", onClick={() => setShowExitDialog(true)}
)} >
> <svg
<div className="flex items-center justify-start overflow-hidden text-sm font-medium"> width="20"
<Button height="20"
size="sm" viewBox="0 0 20 20"
variant="ghost" fill="none"
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80" xmlns="http://www.w3.org/2000/svg"
onClick={() => setShowExitDialog(true)}
> >
<svg <path
width="20" d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14"
height="20" stroke="#666666"
viewBox="0 0 20 20" strokeWidth="1.5"
fill="none" strokeLinecap="round"
xmlns="http://www.w3.org/2000/svg" strokeLinejoin="round"
> />
<path </svg>
d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14" </Button>
stroke="#666666" </div>
strokeWidth="1.5" <div className="flex items-center justify-center overflow-hidden text-sm font-medium">
strokeLinecap="round" {title !== "Untitled" && (
strokeLinejoin="round" <ThreadTitle threadId={threadId} threadTitle={title} />
/>
</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>
)}
</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"> <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>
)}
</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 />
) : (
<MessageList <MessageList
className={cn( className={cn("size-full", !isNewThread && "pt-10")}
"size-full",
(!isNewThread || hasSubmitted) && "pt-[20px]",
)}
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
suppressThreadLoading={suppressExistingThreadPrefetchUi}
messagesOverride={ messagesOverride={
suppressExistingThreadPrefetchUi !thread.isLoading && finalState?.messages
? [] ? (finalState.messages as Message[])
: !thread.isLoading && finalState?.messages : undefined
? (finalState.messages as Message[])
: undefined
} }
paddingBottom={todoListCollapsed ? 160 : 280} paddingBottom={todoListCollapsed ? 160 : 280}
/> />
</div> )}
</main> </div>
</div> </main>
</div> </div>
</ResizablePanel>
<ResizableHandle
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<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 <div
className={cn( className={cn(
"bg-background ml-[20px] rounded-t-[20px] transition-all duration-300 ease-in-out", "h-full w-full transition-transform duration-300 ease-in-out",
!artifactsOpen && "opacity-0", artifactPanelOpen ? "translate-x-0" : "translate-x-full",
artifactPanelOpen
? fullscreen
? "ml-0 w-full"
: "w-[70%]"
: "w-0",
)} )}
> >
<div {selectedArtifact ? (
className={cn( <ArtifactFileDetail
"h-full w-full transition-transform duration-300 ease-in-out", className="size-full"
artifactPanelOpen ? "translate-x-0" : "translate-x-full", filepath={selectedArtifact}
)} threadId={threadId}
> />
{selectedArtifact ? ( ) : (
<ArtifactFileDetail <div className="relative flex size-full justify-center px-[20px]">
className="size-full" <div className="absolute top-1 right-1 z-30">
filepath={selectedArtifact} <Button
threadId={threadId} size="icon-sm"
/> variant="ghost"
) : ( onClick={() => {
<div className="relative flex size-full justify-center px-[20px]"> setArtifactsOpen(false);
<div className="absolute top-2 right-2 z-30"> }}
<Button >
size="icon-sm" <XIcon />
variant="ghost" </Button>
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>
)} {thread.values.artifacts?.length === 0 ? (
</div> <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>
)}
</div>
)}
</div> </div>
</div> </ResizablePanel>
</ResizablePanelGroup>
{/* Fixed 底部居中输入框容器 */} {/* 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 <div
className={cn( className={cn(
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4", "pointer-events-auto relative w-full max-w-[720px]",
"transition-all duration-300 ease-in-out", isNewThread && "top-[-65px] -translate-y-[calc(50vh-96px)]",
fullscreen ? "hidden" : "",
)} )}
> >
<div <InputBox
className={cn( className={cn("w-full rounded-[20px] bg-[#FBFAFC]")}
"pointer-events-auto relative w-full max-w-[720px]", isNewThread={isNewThread}
isNewThread && !hasSubmitted && "-translate-y-[calc(50vh-96px)]", autoFocus={isNewThread}
)} status={
> suppressExistingThreadPrefetchUi
<InputBox ? "ready"
className={cn("w-full rounded-[20px] bg-[#FBFAFC]")} : thread.isLoading
isNewThread={isNewThread} ? "streaming"
hasSubmitted={hasSubmitted} : "ready"
autoFocus={isNewThread} }
status={ context={settings.context}
suppressExistingThreadPrefetchUi extraHeader={
? "ready" <div className="flex flex-col gap-4">
: thread.isLoading {isNewThread && <Welcome mode={settings.context.mode} />}
? "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> </div>
)} }
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( disabled={
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs"> env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
{t.common.notAvailableInDemoMode} isSelectedSkillBootstrapping
</div> }
)} onContextChange={(context) => setSettings("context", context)}
</div> 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={() => {
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> </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> </ThreadContext.Provider>
); );
} }

View File

@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => ( export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div <div
className={cn( className={cn(
"bg-background flex flex-col overflow-hidden rounded-t-[20px] px-[20px] pt-[15px]", "bg-background flex flex-col overflow-hidden rounded-[20px] px-[20px] pt-[15px]",
className, className,
)} )}
{...props} {...props}
@ -30,7 +30,10 @@ export const ArtifactHeader = ({
...props ...props
}: ArtifactHeaderProps) => ( }: ArtifactHeaderProps) => (
<div <div
className={cn("mb-[10px] flex items-center justify-between", className)} className={cn(
"mb-[20px] grid grid-cols-3 items-center justify-between",
className,
)}
{...props} {...props}
/> />
); );
@ -141,6 +144,6 @@ export const ArtifactContent = ({
...props ...props
}: ArtifactContentProps) => ( }: ArtifactContentProps) => (
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
<div className={cn("mb-[150px] p-4", className)} {...props} /> <div className={cn("mb-[208px] p-4", className)} {...props} />
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import { useState } from "react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -21,46 +19,6 @@ interface DropdownSelectorProps<T extends string> {
contentClassName?: 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>({ export function DropdownSelector<T extends string>({
value, value,
options, options,
@ -69,20 +27,16 @@ export function DropdownSelector<T extends string>({
contentClassName, contentClassName,
}: DropdownSelectorProps<T>) { }: DropdownSelectorProps<T>) {
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === value);
const [isOpen, setIsOpen] = useState(false);
return ( return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className={ className={
triggerClassName ?? triggerClassName ??
"border-none bg-transparent shadow-none select-none focus:outline-none" "border-none bg-transparent shadow-none select-none focus:outline-none"
} }
> >
<span className="flex items-center gap-1"> {selectedOption?.label ?? value}
{selectedOption?.label ?? value}
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</span>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className={contentClassName}> <DropdownMenuContent className={contentClassName}>
<DropdownMenuRadioGroup <DropdownMenuRadioGroup

View File

@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
"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", "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",
"h-9 min-w-0 has-[>textarea]:h-auto", "h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.

View File

@ -69,8 +69,8 @@ function ToggleGroupItem({
variant: context.variant || variant, variant: context.variant || variant,
size: context.size || size, size: context.size || size,
}), }),
"h-full w-[50px] min-w-0 shrink-0 cursor-pointer bg-white px-3 focus:z-10 focus-visible:z-10", "w-auto min-w-0 shrink-0 cursor-pointer 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", "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, className,
)} )}
{...props} {...props}

View File

@ -43,7 +43,6 @@ import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
@ -65,8 +64,8 @@ export function ArtifactFileDetail({
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select, fullscreen, setFullscreen } = const { artifacts, setOpen, select } = useArtifacts();
useArtifacts(); const [fullscreen, setFullscreen] = useState(false);
const isWriteFile = useMemo(() => { const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
@ -112,17 +111,33 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(80); const [zoom, setZoom] = useState(100);
// 全屏切换处理 // 全屏切换处理
const handleFullscreenToggle = useCallback(() => { const handleFullscreenToggle = useCallback(() => {
const newFullscreen = !fullscreen; if (!document.fullscreenElement) {
setFullscreen(newFullscreen); document.documentElement.requestFullscreen().catch((err) => {
sendToParent({ console.error("无法进入全屏模式:", err);
type: POST_MESSAGE_TYPES.FULLSCREEN, });
fullscreen: newFullscreen, setFullscreen(true);
}); } else {
}, [fullscreen, setFullscreen]); 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]);
useEffect(() => { useEffect(() => {
if (previewable) { if (previewable) {
@ -160,9 +175,8 @@ export function ArtifactFileDetail({
{previewable && ( {previewable && (
<ToggleGroup <ToggleGroup
type="single" type="single"
variant={null} variant="outline"
size="default" size="sm"
className="h-[28px]"
value={viewMode} value={viewMode}
onValueChange={(value) => { onValueChange={(value) => {
if (value) { if (value) {
@ -171,52 +185,13 @@ export function ArtifactFileDetail({
}} }}
> >
<ToggleGroupItem value="code"> <ToggleGroupItem value="code">
<svg <Code2Icon />
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>
<ToggleGroupItem value="preview"> <ToggleGroupItem value="preview">
<svg <EyeIcon />
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> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)} )}
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
</div> </div>
<div className="flex min-w-0 grow items-center justify-center"> <div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle> <ArtifactTitle>
@ -232,6 +207,8 @@ export function ArtifactFileDetail({
</ArtifactTitle> </ArtifactTitle>
</div> </div>
<div className="flex items-center justify-end overflow-hidden"> <div className="flex items-center justify-end overflow-hidden">
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
<ArtifactActions> <ArtifactActions>
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
@ -363,27 +340,25 @@ export function ArtifactFileDetail({
</svg> </svg>
)} )}
</ArtifactAction> </ArtifactAction>
{!fullscreen && ( <ArtifactAction
<ArtifactAction label={t.common.close}
label={t.common.close} onClick={() => setOpen(false)}
onClick={() => setOpen(false)} tooltip={t.common.close}
tooltip={t.common.close} >
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
> >
<svg <path
width="18" d="M4 14L14 4M4 4L14 14"
height="18" stroke="#666666"
viewBox="0 0 18 18" stroke-linecap="round"
fill="none" />
xmlns="http://www.w3.org/2000/svg" </svg>
> </ArtifactAction>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
stroke-linecap="round"
/>
</svg>
</ArtifactAction>
)}
</ArtifactActions> </ArtifactActions>
</div> </div>
</ArtifactHeader> </ArtifactHeader>
@ -496,7 +471,8 @@ export const ArtifactZoomSelector = ({
return ( return (
<div <div
className={cn( className={cn(
"inline-flex h-[28px] items-center gap-1 rounded-[10px] bg-white backdrop-blur-sm", "inline-flex items-center gap-2 rounded-[10px] bg-white px-2 py-1 backdrop-blur-sm",
"border border-gray-200/50",
"dark:border-gray-700/50 dark:bg-gray-800/90", "dark:border-gray-700/50 dark:bg-gray-800/90",
className, className,
)} )}
@ -507,36 +483,18 @@ export const ArtifactZoomSelector = ({
onClick={handleZoomIn} onClick={handleZoomIn}
disabled={!canZoomIn} disabled={!canZoomIn}
className={cn( className={cn(
"flex h-full w-10 items-center justify-center rounded py-1 transition-colors", "flex h-6 w-6 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600", "text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent", "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-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)} )}
aria-label="放大" aria-label="放大"
> >
<svg <ZoomIn className="h-3.5 w-3.5" />
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> </button>
<span <span
className={cn( className={cn(
"min-w-[36px] text-center text-xs font-medium text-gray-600", "min-w-[42px] text-center text-xs font-medium text-gray-600",
"dark:text-gray-300", "dark:text-gray-300",
)} )}
> >
@ -547,32 +505,14 @@ export const ArtifactZoomSelector = ({
onClick={handleZoomOut} onClick={handleZoomOut}
disabled={!canZoomOut} disabled={!canZoomOut}
className={cn( className={cn(
"flex h-full w-10 items-center justify-center rounded transition-colors", "flex h-6 w-6 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600", "text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent", "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-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)} )}
aria-label="缩小" aria-label="缩小"
> >
<svg <ZoomOut className="h-3.5 w-3.5" />
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> </button>
</div> </div>
); );

View File

@ -90,7 +90,6 @@ export function InputBox({
context, context,
extraHeader, extraHeader,
isNewThread, isNewThread,
hasSubmitted,
initialValue, initialValue,
onContextChange, onContextChange,
onSubmit, onSubmit,
@ -108,7 +107,6 @@ export function InputBox({
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
hasSubmitted?: boolean;
initialValue?: string; initialValue?: string;
onContextChange?: ( onContextChange?: (
context: Omit< context: Omit<
@ -141,9 +139,8 @@ export function InputBox({
); );
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息) // isNewThread 时禁用收缩,始终保持展开
const effectiveIsFocused = const effectiveIsFocused = (isNewThread ?? false) || isFocused;
((isNewThread ?? false) && !hasSubmitted) || isFocused;
// 点击外部区域时收起输入框 // 点击外部区域时收起输入框
useEffect(() => { useEffect(() => {
@ -305,7 +302,7 @@ export function InputBox({
)} )}
inputGroupClassName={cn( inputGroupClassName={cn(
"border-0 rounded-[20px] backdrop-blur-sm", "border-0 rounded-[20px] backdrop-blur-sm",
"transition-[height] duration-300 ease-out shadow-none ", "transition-[height] duration-300 ease-out",
!isNewThread && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]", !isNewThread && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
effectiveIsFocused ? "h-[200px]" : "h-[80px]", effectiveIsFocused ? "h-[200px]" : "h-[80px]",
)} )}
@ -379,7 +376,7 @@ export function InputBox({
/> />
</PromptInput> </PromptInput>
{isNewThread && !hasSubmitted && searchParams.get("mode") !== "skill" && ( {isNewThread && searchParams.get("mode") !== "skill" && (
<SuggestionListContainer <SuggestionListContainer
sendSelectSkill={iframeSkill.sendSelectSkill} sendSelectSkill={iframeSkill.sendSelectSkill}
/> />

View File

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

View File

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

View File

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

View File

@ -1,59 +0,0 @@
/**
* 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,6 +18,29 @@ import type {
AgentThreadState, AgentThreadState,
} from "./types"; } 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({ export function useThreadStream({
threadId, threadId,
isNewThread, isNewThread,
@ -189,6 +212,10 @@ export function useSubmitThread({
}, },
); );
if (createNewSession && isNewThread && threadId) {
await waitForThreadStateToBeReadable(apiClient, threadId);
}
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.(); afterSubmit?.();
}, },

View File

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

View File

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