mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: resolve eslint warnings in agent chat
This commit is contained in:
@@ -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<InputbarProps> = ({
|
||||
@@ -33,6 +104,11 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
onClearMessages,
|
||||
onToggleCanvas: _onToggleCanvas,
|
||||
isCanvasOpen: _isCanvasOpen,
|
||||
taskFiles = [],
|
||||
selectedFileId,
|
||||
taskFilesExpanded = false,
|
||||
onToggleTaskFiles,
|
||||
onTaskFileClick,
|
||||
}) => {
|
||||
const [activeTools, setActiveTools] = useState<Record<string, boolean>>({});
|
||||
const [pendingImages, setPendingImages] = useState<MessageImage[]>([]);
|
||||
@@ -193,6 +269,10 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
setPendingImages([]);
|
||||
}, [input, pendingImages, onSend, activeTools]);
|
||||
|
||||
const handleToggleTaskFiles = useCallback(() => {
|
||||
onToggleTaskFiles?.();
|
||||
}, [onToggleTaskFiles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
@@ -201,6 +281,40 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
isFullscreen ? "fixed inset-0 z-50 bg-background p-4 flex flex-col" : ""
|
||||
}
|
||||
>
|
||||
{/* 任务文件区域 - 在输入框上方 */}
|
||||
{taskFiles.length > 0 && (
|
||||
<TaskFilesArea>
|
||||
{/* 按钮和面板的包装容器 */}
|
||||
<TaskFilesWrapper>
|
||||
{/* 任务文件面板 */}
|
||||
<TaskFileList
|
||||
files={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
onFileClick={onTaskFileClick}
|
||||
expanded={taskFilesExpanded}
|
||||
onExpandedChange={(expanded) => {
|
||||
if (expanded !== taskFilesExpanded) {
|
||||
onToggleTaskFiles?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* 任务文件按钮 */}
|
||||
<TaskFilesButton
|
||||
$hasFiles={taskFiles.length > 0}
|
||||
$expanded={taskFilesExpanded}
|
||||
onClick={handleToggleTaskFiles}
|
||||
data-task-files-trigger
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
任务文件
|
||||
<FileCount>({taskFiles.length})</FileCount>
|
||||
<ChevronIcon $expanded={taskFilesExpanded}>
|
||||
<ChevronUp size={14} />
|
||||
</ChevronIcon>
|
||||
</TaskFilesButton>
|
||||
</TaskFilesWrapper>
|
||||
</TaskFilesArea>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* @file 任务文件列表组件
|
||||
* @description 展示内容创作过程中生成的文件列表
|
||||
* 完全按照 AnyGen 的文件面板样式实现
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import React, { memo, useRef, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { FileText, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
List,
|
||||
Copy,
|
||||
Image,
|
||||
Code,
|
||||
MoreHorizontal,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 任务文件类型 */
|
||||
export interface TaskFile {
|
||||
@@ -33,131 +44,488 @@ interface TaskFileListProps {
|
||||
onExpandedChange?: (expanded: boolean) => 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<TaskFileListProps> = memo(
|
||||
({
|
||||
files,
|
||||
selectedFileId,
|
||||
onFileClick,
|
||||
expanded = true,
|
||||
expanded = false,
|
||||
onExpandedChange,
|
||||
}) => {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [currentFolder, setCurrentFolder] = useState<string | null>(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 (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>
|
||||
<FileText size={14} />
|
||||
生成的文件
|
||||
<FileCount>{files.length}</FileCount>
|
||||
</Title>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExpandedChange?.(!expanded)}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</Button>
|
||||
</Header>
|
||||
|
||||
{expanded && (
|
||||
<FileList>
|
||||
{files.map((file) => (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
$selected={selectedFileId === file.id}
|
||||
onClick={() => onFileClick?.(file)}
|
||||
>
|
||||
<FileIcon>
|
||||
<FileText size={16} />
|
||||
</FileIcon>
|
||||
<FileName title={file.name}>{file.name}</FileName>
|
||||
{file.version > 1 && <FileVersion>v{file.version}</FileVersion>}
|
||||
</FileItem>
|
||||
))}
|
||||
</FileList>
|
||||
<FilePanel ref={panelRef}>
|
||||
<PanelHeader>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem onClick={() => setCurrentFolder(null)}>
|
||||
<FolderOpen size={14} />
|
||||
所有文件
|
||||
</BreadcrumbItem>
|
||||
{currentFolder && (
|
||||
<>
|
||||
<BreadcrumbSeparator>
|
||||
<ChevronRight size={14} />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>{currentFolder}</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</Breadcrumb>
|
||||
<Toolbar>
|
||||
<ToolbarButton
|
||||
title={viewMode === "list" ? "网格视图" : "列表视图"}
|
||||
onClick={handleToggleView}
|
||||
$active={viewMode === "grid"}
|
||||
>
|
||||
<List size={16} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton title="复制全部" onClick={handleCopyAll}>
|
||||
<Copy size={16} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
title="筛选图片"
|
||||
onClick={handleFilterImage}
|
||||
$active={filterType === "image"}
|
||||
>
|
||||
<Image size={16} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
title="筛选代码"
|
||||
onClick={handleFilterCode}
|
||||
$active={filterType === "code"}
|
||||
>
|
||||
<Code size={16} />
|
||||
</ToolbarButton>
|
||||
</Toolbar>
|
||||
</PanelHeader>
|
||||
|
||||
<FileListContainer>
|
||||
{displayFiles.length === 0 ? (
|
||||
<EmptyState>
|
||||
<EmptyIcon>
|
||||
<FileText size={40} strokeWidth={1} />
|
||||
</EmptyIcon>
|
||||
<EmptyText>未找到任何项目</EmptyText>
|
||||
</EmptyState>
|
||||
) : (
|
||||
displayFiles.map((file) => {
|
||||
const color = getFileColor(file.type, file.name);
|
||||
return (
|
||||
<FileItem
|
||||
key={file.id}
|
||||
$selected={selectedFileId === file.id}
|
||||
onClick={() => onFileClick?.(file)}
|
||||
>
|
||||
<FileIconWrapper $color={color}>
|
||||
<Folder size={18} />
|
||||
</FileIconWrapper>
|
||||
<FileInfo>
|
||||
<FileName title={file.name}>{file.name}</FileName>
|
||||
<FileMeta>
|
||||
{formatRelativeTime(file.updatedAt)}
|
||||
</FileMeta>
|
||||
</FileInfo>
|
||||
<MoreButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: 显示更多操作菜单
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</MoreButton>
|
||||
</FileItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</FileListContainer>
|
||||
</FilePanel>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const restoredFilesSessionId = useRef<string | null>(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 && (
|
||||
<TaskFileList
|
||||
files={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
onFileClick={handleTaskFileClick}
|
||||
expanded={taskFilesExpanded}
|
||||
onExpandedChange={setTaskFilesExpanded}
|
||||
/>
|
||||
)}
|
||||
<Inputbar
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
@@ -657,6 +790,11 @@ export function AgentChatPage({
|
||||
onClearMessages={handleClearMessages}
|
||||
onToggleCanvas={handleToggleCanvas}
|
||||
isCanvasOpen={layoutMode === "chat-canvas"}
|
||||
taskFiles={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
taskFilesExpanded={taskFilesExpanded}
|
||||
onToggleTaskFiles={() => 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 (
|
||||
<CanvasFactory
|
||||
theme={mappedTheme}
|
||||
theme={effectiveTheme}
|
||||
state={canvasState}
|
||||
onStateChange={setCanvasState}
|
||||
onClose={handleCloseCanvas}
|
||||
|
||||
Reference in New Issue
Block a user