diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 9e5eabd1b..fe795a728 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -2,7 +2,68 @@ import React from "react"; import { InputbarCore } from "./components/InputbarCore"; import { toast } from "sonner"; import { useState, useCallback, useRef } from "react"; +import styled from "styled-components"; import type { MessageImage } from "../../types"; +import { TaskFileList, type TaskFile } from "../TaskFiles"; +import { FolderOpen, ChevronUp } from "lucide-react"; + +// 任务文件触发器区域(在输入框上方,与输入框对齐) +const TaskFilesArea = styled.div` + display: flex; + justify-content: flex-end; + padding: 0 18px 8px 18px; + width: 100%; + max-width: 900px; + margin: 0 auto; +`; + +// 按钮和面板的包装容器 +const TaskFilesWrapper = styled.div` + position: relative; +`; + +// 任务文件按钮 +const TaskFilesButton = styled.button<{ + $expanded?: boolean; + $hasFiles?: boolean; +}>` + display: ${(props) => (props.$hasFiles ? "flex" : "none")}; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + font-size: 13px; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: hsl(var(--primary) / 0.5); + color: hsl(var(--foreground)); + } + + ${(props) => + props.$expanded && + ` + border-color: hsl(var(--primary)); + color: hsl(var(--foreground)); + background: hsl(var(--primary) / 0.05); + `} +`; + +const FileCount = styled.span` + font-weight: 500; +`; + +const ChevronIcon = styled.span<{ $expanded?: boolean }>` + display: flex; + align-items: center; + transform: ${(props) => + props.$expanded ? "rotate(0deg)" : "rotate(180deg)"}; + transition: transform 0.2s; +`; interface InputbarProps { input: string; @@ -21,6 +82,16 @@ interface InputbarProps { onToggleCanvas?: () => void; /** 画布是否打开 */ isCanvasOpen?: boolean; + /** 任务文件列表 */ + taskFiles?: TaskFile[]; + /** 选中的文件 ID */ + selectedFileId?: string; + /** 任务文件面板是否展开 */ + taskFilesExpanded?: boolean; + /** 切换任务文件面板 */ + onToggleTaskFiles?: () => void; + /** 文件点击回调 */ + onTaskFileClick?: (file: TaskFile) => void; } export const Inputbar: React.FC = ({ @@ -33,6 +104,11 @@ export const Inputbar: React.FC = ({ onClearMessages, onToggleCanvas: _onToggleCanvas, isCanvasOpen: _isCanvasOpen, + taskFiles = [], + selectedFileId, + taskFilesExpanded = false, + onToggleTaskFiles, + onTaskFileClick, }) => { const [activeTools, setActiveTools] = useState>({}); const [pendingImages, setPendingImages] = useState([]); @@ -193,6 +269,10 @@ export const Inputbar: React.FC = ({ setPendingImages([]); }, [input, pendingImages, onSend, activeTools]); + const handleToggleTaskFiles = useCallback(() => { + onToggleTaskFiles?.(); + }, [onToggleTaskFiles]); + return (
= ({ isFullscreen ? "fixed inset-0 z-50 bg-background p-4 flex flex-col" : "" } > + {/* 任务文件区域 - 在输入框上方 */} + {taskFiles.length > 0 && ( + + {/* 按钮和面板的包装容器 */} + + {/* 任务文件面板 */} + { + if (expanded !== taskFilesExpanded) { + onToggleTaskFiles?.(); + } + }} + /> + {/* 任务文件按钮 */} + 0} + $expanded={taskFilesExpanded} + onClick={handleToggleTaskFiles} + data-task-files-trigger + > + + 任务文件 + ({taskFiles.length}) + + + + + + + )} void; } +// 外层容器 const Container = styled.div` - padding: 8px 16px; - border-top: 1px solid hsl(var(--border)); - background: hsl(var(--muted) / 0.3); + position: relative; `; -const Header = styled.div` +// 展开的文件面板 +const FilePanel = styled.div` + position: absolute; + bottom: calc(100% + 8px); + right: 0; + width: 320px; + max-height: 400px; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + overflow: hidden; + z-index: 100; + display: flex; + flex-direction: column; +`; + +// 面板头部 +const PanelHeader = styled.div` display: flex; align-items: center; justify-content: space-between; - margin-bottom: 8px; + padding: 12px 14px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + flex-shrink: 0; `; -const Title = styled.div` +// 面包屑导航 +const Breadcrumb = styled.div` display: flex; align-items: center; - gap: 8px; + gap: 4px; font-size: 13px; - font-weight: 500; color: hsl(var(--muted-foreground)); `; -const FileCount = styled.span` - background: hsl(var(--primary) / 0.1); - color: hsl(var(--primary)); - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; -`; - -const FileList = styled.div` - display: flex; - flex-wrap: wrap; - gap: 8px; -`; - -const FileItem = styled.div<{ $selected?: boolean }>` +const BreadcrumbItem = styled.button` display: flex; align-items: center; - gap: 8px; - padding: 8px 12px; - background: ${(props) => - props.$selected ? "hsl(var(--primary) / 0.1)" : "hsl(var(--background))"}; - border: 1px solid - ${(props) => - props.$selected ? "hsl(var(--primary))" : "hsl(var(--border))"}; - border-radius: 8px; + gap: 4px; + padding: 2px 6px; + background: transparent; + border: none; + border-radius: 4px; + font-size: 13px; + color: hsl(var(--muted-foreground)); cursor: pointer; - transition: all 0.2s; + transition: all 0.15s; &:hover { - border-color: hsl(var(--primary)); - background: hsl(var(--primary) / 0.05); + background: hsl(var(--muted) / 0.5); + color: hsl(var(--foreground)); } `; -const FileIcon = styled.div` - color: hsl(var(--primary)); +const BreadcrumbSeparator = styled.span` + color: hsl(var(--muted-foreground) / 0.5); + display: flex; + align-items: center; `; -const FileName = styled.span` - font-size: 13px; - color: hsl(var(--foreground)); - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +// 工具栏 +const Toolbar = styled.div` + display: flex; + align-items: center; + gap: 2px; `; -const FileVersion = styled.span` - font-size: 11px; +const ToolbarButton = styled.button<{ $active?: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: ${(props) => + props.$active ? "hsl(var(--primary) / 0.1)" : "transparent"}; + border-radius: 6px; + color: ${(props) => + props.$active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: hsl(var(--muted) / 0.5); + color: hsl(var(--foreground)); + } +`; + +// 文件列表区域 +const FileListContainer = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; + min-height: 100px; + max-height: 320px; + + /* 自定义滚动条 */ + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 3px; + } + &::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); + } +`; + +// 空状态 +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; color: hsl(var(--muted-foreground)); `; +const EmptyIcon = styled.div` + margin-bottom: 12px; + opacity: 0.5; +`; + +const EmptyText = styled.div` + font-size: 13px; +`; + +// 文件项 +const FileItem = styled.div<{ $selected?: boolean }>` + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + background: ${(props) => + props.$selected ? "hsl(var(--primary) / 0.08)" : "transparent"}; + + &:hover { + background: ${(props) => + props.$selected + ? "hsl(var(--primary) / 0.12)" + : "hsl(var(--muted) / 0.4)"}; + } +`; + +// 文件图标容器 +const FileIconWrapper = styled.div<{ $color?: string }>` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 6px; + background: ${(props) => props.$color || "#f59e0b"}20; + color: ${(props) => props.$color || "#f59e0b"}; + flex-shrink: 0; +`; + +const FileInfo = styled.div` + flex: 1; + min-width: 0; +`; + +const FileName = styled.div` + font-size: 13px; + font-weight: 450; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const FileMeta = styled.div` + font-size: 11px; + color: hsl(var(--muted-foreground)); + margin-top: 2px; +`; + +const MoreButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: 4px; + color: hsl(var(--muted-foreground)); + cursor: pointer; + opacity: 0; + transition: all 0.15s; + flex-shrink: 0; + + ${FileItem}:hover & { + opacity: 1; + } + + &:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + } +`; + +// 底部触发按钮(放在 Inputbar 中) +export const TaskFilesTrigger = styled.button<{ + $expanded?: boolean; + $hasFiles?: boolean; +}>` + display: ${(props) => (props.$hasFiles ? "flex" : "none")}; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: transparent; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s; + margin-right: 8px; + + &:hover { + border-color: hsl(var(--primary) / 0.5); + color: hsl(var(--foreground)); + } + + ${(props) => + props.$expanded && + ` + border-color: hsl(var(--primary)); + color: hsl(var(--foreground)); + background: hsl(var(--primary) / 0.05); + `} +`; + +/** + * 格式化相对时间 + */ +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes} 分钟前`; + if (hours < 24) return `${hours} 小时前`; + if (days === 1) return "1 天前"; + if (days < 7) return `${days} 天前`; + return new Date(timestamp).toLocaleDateString("zh-CN"); +} + +/** + * 根据文件类型获取图标颜色 + */ +function getFileColor(type: string, name: string): string { + // 根据文件扩展名判断 + if (name.endsWith(".md") || name.endsWith(".txt")) { + return "#3b82f6"; // 蓝色 - 文档 + } + if ( + name.endsWith(".pptx") || + name.endsWith(".ppt") || + name.endsWith(".key") + ) { + return "#f59e0b"; // 橙色 - 演示文稿 + } + if (name.endsWith(".doc") || name.endsWith(".docx")) { + return "#2563eb"; // 深蓝 - Word文档 + } + if (name.endsWith(".xls") || name.endsWith(".xlsx")) { + return "#10b981"; // 绿色 - Excel + } + if (name.endsWith(".pdf")) { + return "#ef4444"; // 红色 - PDF + } + if (type === "image") { + return "#8b5cf6"; // 紫色 - 图片 + } + if (type === "audio") { + return "#ec4899"; // 粉色 - 音频 + } + return "#f59e0b"; // 默认橙色 +} + /** * 任务文件列表组件 * - * 展示内容创作过程中生成的文件列表,支持点击查看文件内容 + * 展示内容创作过程中生成的文件列表 + * 采用 AnyGen 风格的浮动面板设计 */ export const TaskFileList: React.FC = memo( ({ files, selectedFileId, onFileClick, - expanded = true, + expanded = false, onExpandedChange, }) => { + const panelRef = useRef(null); + const [currentFolder, setCurrentFolder] = useState(null); + const [filterType, setFilterType] = useState<"all" | "image" | "code">( + "all", + ); + const [viewMode, setViewMode] = useState<"list" | "grid">("list"); + + // 点击外部关闭面板 + useEffect(() => { + if (!expanded) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + // 检查点击是否在面板外部 + if (panelRef.current && !panelRef.current.contains(target)) { + // 检查是否点击了触发按钮(在 Inputbar 中) + const triggerButton = document.querySelector( + "[data-task-files-trigger]", + ); + if (triggerButton && triggerButton.contains(target)) { + return; + } + onExpandedChange?.(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, [expanded, onExpandedChange]); + + // 复制所有文件内容 + const handleCopyAll = () => { + const allContent = files + .filter((f) => f.content) + .map((f) => `=== ${f.name} ===\n${f.content}`) + .join("\n\n"); + if (allContent) { + navigator.clipboard.writeText(allContent); + toast.success("已复制所有文件内容"); + } else { + toast.info("没有可复制的内容"); + } + }; + + // 切换筛选类型 + const handleFilterImage = () => { + setFilterType((prev) => (prev === "image" ? "all" : "image")); + }; + + const handleFilterCode = () => { + setFilterType((prev) => (prev === "code" ? "all" : "code")); + }; + + // 切换视图模式 + const handleToggleView = () => { + setViewMode((prev) => (prev === "list" ? "grid" : "list")); + }; + if (files.length === 0) { return null; } + // 过滤文件 + let displayFiles = currentFolder + ? files.filter((f) => f.name.startsWith(currentFolder + "/")) + : files; + + // 根据类型筛选 + if (filterType === "image") { + displayFiles = displayFiles.filter( + (f) => + f.type === "image" || /\.(png|jpg|jpeg|gif|svg|webp)$/i.test(f.name), + ); + } else if (filterType === "code") { + displayFiles = displayFiles.filter((f) => + /\.(js|ts|jsx|tsx|py|go|rs|java|cpp|c|h|css|html|json|md)$/i.test( + f.name, + ), + ); + } + return ( -
- - <FileText size={14} /> - 生成的文件 - <FileCount>{files.length}</FileCount> - - -
- {expanded && ( - - {files.map((file) => ( - onFileClick?.(file)} - > - - - - {file.name} - {file.version > 1 && v{file.version}} - - ))} - + + + + setCurrentFolder(null)}> + + 所有文件 + + {currentFolder && ( + <> + + + + {currentFolder} + + )} + + + + + + + + + + + + + + + + + + + {displayFiles.length === 0 ? ( + + + + + 未找到任何项目 + + ) : ( + displayFiles.map((file) => { + const color = getFileColor(file.type, file.name); + return ( + onFileClick?.(file)} + > + + + + + {file.name} + + {formatRelativeTime(file.updatedAt)} + + + { + e.stopPropagation(); + // TODO: 显示更多操作菜单 + }} + > + + + + ); + }) + )} + + )}
); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 8cdfa1f64..498be5a42 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -15,7 +15,7 @@ import { ChatSettings } from "./components/ChatSettings"; import { MessageList } from "./components/MessageList"; import { Inputbar } from "./components/Inputbar"; import { EmptyState, CreationMode } from "./components/EmptyState"; -import { TaskFileList, type TaskFile } from "./components/TaskFiles"; +import { type TaskFile } from "./components/TaskFiles"; import { LayoutTransition } from "@/components/content-creator/core/LayoutTransition/LayoutTransition"; import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress"; import { useWorkflow } from "@/components/content-creator/hooks/useWorkflow"; @@ -166,13 +166,121 @@ export function AgentChatPage({ }); // 会话文件持久化 hook - const { saveFile: saveSessionFile } = useSessionFiles({ + const { + saveFile: saveSessionFile, + files: sessionFiles, + readFile: readSessionFile, + meta: sessionMeta, + } = useSessionFiles({ sessionId, theme: mappedTheme, creationMode, autoInit: true, }); + // 追踪已恢复元数据和文件的会话 ID + const restoredMetaSessionId = useRef(null); + const restoredFilesSessionId = useRef(null); + + // 当 sessionMeta 加载完成时,恢复主题和创建模式 + useEffect(() => { + if (!sessionId || !sessionMeta) { + return; + } + + // 检查 sessionMeta 是否属于当前 sessionId + if (sessionMeta.sessionId !== sessionId) { + return; + } + + // 避免重复恢复 + if (restoredMetaSessionId.current === sessionId) { + return; + } + + console.log("[AgentChatPage] 恢复会话元数据:", sessionId, sessionMeta); + + // 从会话元数据恢复主题 + if (sessionMeta.theme) { + const themeEntry = Object.entries(THEME_MAP).find( + ([_, v]) => v === sessionMeta.theme, + ); + if (themeEntry) { + console.log("[AgentChatPage] 恢复主题:", themeEntry[0]); + setActiveTheme(themeEntry[0]); + } + } + + // 从会话元数据恢复创建模式 + if (sessionMeta.creationMode) { + console.log("[AgentChatPage] 恢复创建模式:", sessionMeta.creationMode); + setCreationMode(sessionMeta.creationMode as CreationMode); + } + + restoredMetaSessionId.current = sessionId; + }, [sessionId, sessionMeta]); + + // 当 sessionFiles 加载完成时,恢复文件到 taskFiles + useEffect(() => { + if (!sessionId || sessionFiles.length === 0) { + return; + } + + // 避免重复恢复 + if (restoredFilesSessionId.current === sessionId) { + return; + } + + // 如果当前已有 taskFiles,说明是本次会话新生成的文件,不需要从持久化恢复 + if (taskFiles.length > 0) { + restoredFilesSessionId.current = sessionId; + return; + } + + console.log( + "[AgentChatPage] 开始恢复文件:", + sessionId, + sessionFiles.length, + "个文件", + ); + + // 恢复文件到 taskFiles + const restoreFiles = async () => { + const restoredFiles: TaskFile[] = []; + + for (const file of sessionFiles) { + try { + const content = await readSessionFile(file.name); + if (content) { + restoredFiles.push({ + id: crypto.randomUUID(), + name: file.name, + type: file.fileType === "document" ? "document" : "document", + content, + version: 1, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }); + } + } catch (err) { + console.error("[AgentChatPage] 恢复文件失败:", file.name, err); + } + } + + if (restoredFiles.length > 0) { + console.log( + "[AgentChatPage] 从持久化存储恢复", + restoredFiles.length, + "个文件", + ); + setTaskFiles(restoredFiles); + } + restoredFilesSessionId.current = sessionId; + }; + + restoreFiles(); + }, [sessionId, sessionFiles, readSessionFile, taskFiles.length]); + // 包装 switchTopic,在切换话题时重置相关状态 const switchTopic = useCallback( async (topicId: string) => { @@ -182,6 +290,9 @@ export function AgentChatPage({ setTaskFiles([]); setSelectedFileId(undefined); processedMessageIds.current.clear(); + // 清空已恢复的会话 ID,以便新话题能触发恢复 + restoredMetaSessionId.current = null; + restoredFilesSessionId.current = null; // 然后调用原始的 switchTopic await originalSwitchTopic(topicId); @@ -308,6 +419,38 @@ export function AgentChatPage({ } }, [hasMessages]); + // 当有文件时默认在画布中显示最后一个文件 + useEffect(() => { + if (taskFiles.length > 0) { + const lastFile = taskFiles[taskFiles.length - 1]; + // 设置选中的文件 + setSelectedFileId(lastFile.id); + // 如果文件有内容,在画布中显示 + if (lastFile.content) { + setCanvasState((prev) => { + if (mappedTheme === "music") { + const sections = parseLyrics(lastFile.content!); + if (!prev || prev.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = lastFile.content!.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...prev, sections }; + } + if (!prev || prev.type !== "document") { + return createInitialDocumentState(lastFile.content!); + } + return { ...prev, content: lastFile.content! }; + }); + setLayoutMode("chat-canvas"); + } + } + }, [taskFiles, mappedTheme]); + const handleToggleSidebar = () => { setShowSidebar(!showSidebar); }; @@ -637,16 +780,6 @@ export function AgentChatPage({ {hasMessages && ( <> - {/* 任务文件列表 - 显示在输入框上方 */} - {taskFiles.length > 0 && ( - - )} setTaskFilesExpanded(!taskFilesExpanded)} + onTaskFileClick={handleTaskFileClick} /> )} @@ -671,10 +809,18 @@ export function AgentChatPage({ isSupported: isCanvasSupported(mappedTheme), layoutMode, }); - if (canvasState && isCanvasSupported(mappedTheme)) { + // 只要有 canvasState 就显示画布(支持显示任意主题的文件) + if (canvasState) { + // 根据 canvasState.type 确定使用的主题 + const effectiveTheme: ThemeType = + canvasState.type === "music" + ? "music" + : canvasState.type === "poster" + ? "poster" + : "document"; return (