feat(agent): enhance Codex thread management with workspace verification and improved thread loading

This commit is contained in:
HouYunFei
2026-06-16 13:22:15 +08:00
parent 7f0199da59
commit b4515ed85f
11 changed files with 54 additions and 561 deletions
-288
View File
@@ -1,288 +0,0 @@
"use client";
import { Copy, FolderPlus, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd";
import axios from "axios";
import { useCopyText } from "@/hooks/use-copy-text";
import { cn } from "@/lib/utils";
import { useAssetStore } from "@/stores/use-asset-store";
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
import { uploadImage } from "@/services/image-storage";
const PAGE_SIZE = 12;
export default function AssetLibraryPage() {
const { message } = App.useApp();
const copyText = useCopyText();
const [keyword, setKeyword] = useState("");
const [selectedType, setSelectedType] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
const addAsset = useAssetStore((state) => state.addAsset);
const query = useQuery({
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
retry: false,
});
useEffect(() => {
if (query.isError) {
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
}
}, [message, query.error, query.isError]);
const isReady = query.isFetched || query.isError;
const items = query.data?.items || [];
const availableTags = query.data?.tags || [];
const total = query.data?.total || 0;
const toggleTag = (tag: string) => {
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
};
const saveToMyAssets = async (asset: AssetLibraryItem) => {
try {
if (asset.type === "image") {
const dataUrl = await remoteImageToDataUrl(asset.url);
const image = await uploadImage(dataUrl);
addAsset({
kind: "image",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
metadata: { source: "asset-library", assetId: asset.id },
});
} else {
addAsset({
kind: "text",
title: asset.title,
coverUrl: asset.coverUrl,
tags: asset.tags,
source: asset.category,
note: asset.description,
data: { content: asset.content },
metadata: { source: "asset-library", assetId: asset.id },
});
}
message.success("已加入我的素材");
} catch {
message.error("加入失败");
}
};
if (!isReady) {
return (
<div className="flex h-full items-center justify-center">
<Spin />
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
<div className="pb-8">
<div className="mx-auto max-w-5xl text-center">
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100"></h1>
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">使</p>
</div>
<div className="mx-auto mt-8 w-full max-w-2xl">
<Input
size="large"
className="w-full"
prefix={<Search className="size-4 text-stone-400" />}
value={keyword}
placeholder="按标题查询"
onChange={(event) => {
setPage(1);
setKeyword(event.target.value);
}}
/>
</div>
<div className="mx-auto mt-6 max-w-6xl space-y-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((item) => (
<Tag.CheckableTag
key={item.value || "all"}
checked={selectedType === item.value}
className={cn("prompt-filter-tag", selectedType === item.value && "is-active")}
onChange={() => {
setPage(1);
setSelectedType(item.value);
}}
>
{item.label}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
<Tag.CheckableTag
checked={selectedTags.length === 0}
className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")}
onChange={() => {
setPage(1);
setSelectedTags([]);
}}
>
</Tag.CheckableTag>
{availableTags.map((tag) => (
<Tag.CheckableTag
key={tag}
checked={selectedTags.includes(tag)}
className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")}
onChange={() => {
setPage(1);
toggleTag(tag);
}}
>
{tag}
</Tag.CheckableTag>
))}
</div>
</div>
</div>
</div>
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
{items.map((asset) => (
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
))}
</div>
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
<div className="flex justify-center">
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
</div>
</div>
</main>
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
{selectedAsset ? (
<div className="space-y-5">
{selectedAsset.coverUrl ? (
<Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" />
) : (
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>
)}
<div>
<Typography.Title level={4} className="!mb-2">
{selectedAsset.title}
</Typography.Title>
<div className="flex flex-wrap gap-1.5">
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
{selectedAsset.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs">
</Typography.Text>
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}>
</Button>
) : null}
{selectedAsset.type === "image" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}>
</Button>
) : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>
</Button>
</div>
</div>
) : null}
</Drawer>
</div>
);
}
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
const cover = asset.coverUrl;
return (
<Card
hoverable
className="overflow-hidden"
styles={{ body: { padding: 0 } }}
cover={
<button type="button" className="block w-full text-left" onClick={onOpen}>
{cover ? (
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>
)}
</button>
}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
</div>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
{asset.type === "text" ? asset.content : asset.url}
</Typography.Paragraph>
<div className="mt-3 flex flex-wrap gap-1.5">
{asset.tags.slice(0, 3).map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
{!asset.tags.length ? <Tag className="m-0 text-[11px]"></Tag> : null}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button size="small" onClick={onOpen}>
</Button>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>
</Button>
</div>
</Card>
);
}
async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return await blobToDataUrl(blob);
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}
@@ -40,7 +40,7 @@ import { Minimap } from "../components/canvas-mini-map";
import { CanvasNode } from "../components/canvas-node";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "../components/canvas-node-prompt-panel";
import { CanvasToolbar } from "../components/canvas-toolbar";
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
import { AssetPickerModal, type InsertAssetPayload } from "../components/asset-picker-modal";
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
import { useCanvasStore } from "../stores/use-canvas-store";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
@@ -275,7 +275,6 @@ function InfiniteCanvasPage() {
const [showImageInfo, setShowImageInfo] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [assetPickerTab, setAssetPickerTab] = useState<AssetPickerTab>("my-assets");
const [projectLoaded, setProjectLoaded] = useState(false);
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
@@ -2567,12 +2566,7 @@ function InfiniteCanvasPage() {
onDeselect={deselectCanvas}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
onOpenAssetLibrary={() => {
setAssetPickerTab("library");
setAssetPickerOpen(true);
}}
onOpenMyAssets={() => {
setAssetPickerTab("my-assets");
setAssetPickerOpen(true);
}}
/>
@@ -2654,7 +2648,7 @@ function InfiniteCanvasPage() {
<p className="text-sm opacity-60">线</p>
</Modal>
<AssetPickerModal open={assetPickerOpen} defaultTab={assetPickerTab} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
<AssetPickerModal open={assetPickerOpen} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
</section>
{assistantMounted ? (
<CanvasAssistantPanel
@@ -1,43 +1,24 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Empty, Input, Modal, Pagination, Spin, Tabs, Tag } from "antd";
import { Empty, Input, Modal, Pagination, Tag } from "antd";
import { Search } from "lucide-react";
import axios from "axios";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
export type AssetPickerTab = "my-assets" | "library";
export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string } | { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number };
type Props = {
open: boolean;
defaultTab?: AssetPickerTab;
onInsert: (payload: InsertAssetPayload) => void;
onClose: () => void;
};
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
useEffect(() => {
if (open) setActiveTab(defaultTab);
}, [open, defaultTab]);
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
return (
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as AssetPickerTab)}
items={[
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
]}
/>
<MyAssetsTab onInsert={onInsert} />
</Modal>
);
}
@@ -51,104 +32,12 @@ const kindOptions = [
{ label: "视频", value: "video" },
];
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [page, setPage] = useState(1);
const [inserting, setInserting] = useState<string | null>(null);
const query = useQuery({
queryKey: ["asset-picker-library", keyword, kindFilter, page],
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
retry: false,
});
const items = query.data?.items || [];
const total = query.data?.total || 0;
const handleInsert = async (asset: AssetLibraryItem) => {
try {
setInserting(asset.id);
if (asset.type === "text") {
onInsert({ kind: "text", content: asset.content, title: asset.title });
} else {
const dataUrl = await remoteImageToDataUrl(asset.url);
onInsert({ kind: "image", dataUrl, title: asset.title });
}
} catch {
message.error("插入失败");
} finally {
setInserting(null);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
value={keyword}
allowClear
onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/>
<div className="flex gap-1.5">
{[
{ label: "全部", value: "" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
].map((opt) => (
<Tag.CheckableTag
key={opt.value || "all"}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(opt.value);
}}
>
{opt.label}
</Tag.CheckableTag>
))}
</div>
</div>
{query.isLoading ? (
<div className="flex justify-center py-16">
<Spin />
</div>
) : items.length ? (
<div className="grid grid-cols-4 gap-3">
{items.map((asset) => (
<PickerCard key={asset.id} title={asset.title} kind={asset.type} cover={asset.coverUrl} loading={inserting === asset.id} onClick={() => void handleInsert(asset)} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
)}
{total > PAGE_SIZE && (
<div className="flex justify-center">
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
</div>
)}
</div>
);
}
function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) {
function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => void }) {
return (
<button
type="button"
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
onClick={onClick}
disabled={loading}
>
{cover ? (
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
@@ -161,27 +50,11 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : "文本"}</Tag>
</div>
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
<Spin size="small" />
</div>
)}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div>
</button>
);
}
async function remoteImageToDataUrl(url: string) {
const response = await axios.get(url, { responseType: "blob" });
const blob = response.data as Blob;
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(blob);
});
}
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
@@ -69,7 +69,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
});
const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId;
if (nextThreadId && !current.messages.length) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`);
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`);
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
}
} catch (error) {
@@ -1,7 +1,7 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useRef, useState } from "react";
import { Button, Segmented, Switch } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Library, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -26,7 +26,6 @@ export function CanvasToolbar({
onDeselect,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenAssetLibrary,
onOpenMyAssets,
}: {
selectedCount: number;
@@ -47,7 +46,6 @@ export function CanvasToolbar({
onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenAssetLibrary: () => void;
onOpenMyAssets: () => void;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
@@ -96,9 +94,6 @@ export function CanvasToolbar({
<Upload className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-library" label="素材库" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenAssetLibrary}>
<Library className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
<FolderOpen className="size-4.5" />
</ToolbarButton>
@@ -287,7 +282,6 @@ function toolLabel(id: string) {
if (id === "tool-audio") return "音频";
if (id === "tool-config") return "生成配置";
if (id === "tool-upload") return "上传素材";
if (id === "tool-library") return "素材库";
if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中";
-45
View File
@@ -1,45 +0,0 @@
export type AssetLibraryItem = {
id: string;
title: string;
type: "text" | "image" | "video";
coverUrl: string;
tags: string[];
category: string;
description: string;
content: string;
url: string;
createdAt: string;
updatedAt: string;
};
export type AssetLibraryResponse = {
items: AssetLibraryItem[];
tags: string[];
total: number;
};
export type AssetLibraryQuery = {
keyword?: string;
type?: string;
tag?: string[];
page?: number;
pageSize?: number;
};
export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) {
const items: AssetLibraryItem[] = [];
const filtered = items.filter((item) => {
const keyword = query.keyword?.trim().toLowerCase();
if (query.type && item.type !== query.type) return false;
if (query.tag?.length && !query.tag.some((tag) => item.tags.includes(tag))) return false;
if (!keyword) return true;
return [item.title, item.description, item.content, item.url, item.category, ...item.tags].join(" ").toLowerCase().includes(keyword);
});
const page = Math.max(1, Number(query.page) || 1);
const pageSize = Math.max(1, Number(query.pageSize) || filtered.length || 1);
return {
items: filtered.slice((page - 1) * pageSize, page * pageSize),
tags: Array.from(new Set(filtered.flatMap((item) => item.tags).filter(Boolean))),
total: filtered.length,
};
}