mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(agent): enhance Codex thread management with workspace verification and improved thread loading
This commit is contained in:
+38
-14
@@ -53,34 +53,39 @@ export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
const thread = await codexApp.resumeThread(threadId, cwd);
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
codexThreadId = String(field(thread, "id") || threadId);
|
||||
return { thread, messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd?: string; all?: boolean; searchTerm?: string; limit?: number }) {
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.listThreads({
|
||||
limit: options.limit || 40,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
||||
...(options.all ? {} : { cwd: options.cwd }),
|
||||
cwd: options.cwd,
|
||||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
||||
});
|
||||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread) : [];
|
||||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
||||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||
}
|
||||
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.readThread(threadId);
|
||||
const thread = field(result, "thread") || {};
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string) {
|
||||
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
}
|
||||
|
||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
await codexApp.archiveThread(threadId);
|
||||
}
|
||||
|
||||
@@ -93,10 +98,11 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
|
||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
|
||||
if (options.threadId) {
|
||||
if (codexThreadId !== options.threadId) {
|
||||
const thread = await app.resumeThread(options.threadId, options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
||||
}
|
||||
const result = await app.readThread(options.threadId, false);
|
||||
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
||||
const thread = await app.resumeThread(options.threadId, options.cwd);
|
||||
assertThreadWorkspace(thread, options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
||||
return codexThreadId;
|
||||
}
|
||||
if (!codexThreadId) {
|
||||
@@ -155,8 +161,8 @@ class CodexAppClient {
|
||||
return this.request("thread/list", params);
|
||||
}
|
||||
|
||||
readThread(threadId: string) {
|
||||
return this.request("thread/read", { threadId, includeTurns: true });
|
||||
readThread(threadId: string, includeTurns = true) {
|
||||
return this.request("thread/read", { threadId, includeTurns });
|
||||
}
|
||||
|
||||
archiveThread(threadId: string) {
|
||||
@@ -291,6 +297,24 @@ function normalizeCodexNotification(method: string, params: Json): AgentEvent |
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.readThread(threadId, includeTurns);
|
||||
const thread = field(result, "thread") || {};
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
return thread;
|
||||
}
|
||||
|
||||
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||||
}
|
||||
|
||||
function threadInWorkspace(thread: unknown, cwd: string) {
|
||||
const threadCwd = String(field(thread, "cwd") || "");
|
||||
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
||||
}
|
||||
|
||||
function normalizeItem(item: unknown) {
|
||||
const value = item && typeof item === "object" ? { ...(item as Json) } : {};
|
||||
if (value.type === "agentMessage") value.type = "agent_message";
|
||||
|
||||
@@ -2,7 +2,7 @@ import express, { type NextFunction, type Request, type Response } from "express
|
||||
|
||||
import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js";
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, withAgentPrompt } from "./agents.js";
|
||||
import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
|
||||
import type { AgentAttachment } from "./types.js";
|
||||
|
||||
export function startHttpServer() {
|
||||
@@ -44,7 +44,7 @@ export function startHttpServer() {
|
||||
});
|
||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
|
||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, all: req.query.all === "1", searchTerm: String(req.query.searchTerm || "") });
|
||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||
res.json({ ok: true, workspace, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/new", route(async (req, res) => {
|
||||
@@ -55,8 +55,9 @@ export function startHttpServer() {
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
res.json({ ok: true, ...(await readCodexThread(emit, threadId)) });
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
@@ -68,7 +69,7 @@ export function startHttpServer() {
|
||||
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
await archiveCodexThread(emit, threadId);
|
||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||
if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined });
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
@@ -81,6 +82,7 @@ export function startHttpServer() {
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
}
|
||||
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
||||
|
||||
@@ -34,7 +34,7 @@ description: 画布常用鼠标与键盘操作说明
|
||||
|
||||
- 拖入图片文件:上传图片到画布。
|
||||
- 导入图片按钮:从本地选择图片。
|
||||
- 素材库或我的素材:选择素材后插入画布。
|
||||
- 我的素材:选择素材后插入画布。
|
||||
|
||||
## 撤销和重做范围
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ description: 当前项目已实现的主要功能
|
||||
|
||||
目前画布中有三类节点:
|
||||
|
||||
- 图片节点:展示上传图片、生成图片或素材库图片。
|
||||
- 图片节点:展示上传图片、生成图片或我的素材图片。
|
||||
- 文本节点:保存提示词、说明文案、AI 文字回答等文本内容。
|
||||
- 生成配置节点:汇总上游文本和图片,统一配置模型、比例、数量后批量生成图片或文本。
|
||||
|
||||
@@ -142,8 +142,6 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 从提示词库和画布节点加入素材。
|
||||
- 在画布中插入素材。
|
||||
|
||||
“素材库”入口目前保留为空的公共素材列表,主要数据仍建议放在“我的素材”中。
|
||||
|
||||
## 配置和同步
|
||||
|
||||
- 当前版本不需要账号登录,也不再提供后台管理页面。
|
||||
@@ -154,7 +152,6 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
|
||||
- 画布项目和“我的素材”目前只保存在浏览器本地,不会随账号同步。
|
||||
- AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合个人或可信环境使用。
|
||||
- 公共素材库暂未接入持久化后端。
|
||||
- Seedance 本地参考视频/音频更建议使用公网可访问 URL;上游是否接受前端传入的本地数据取决于具体兼容接口。
|
||||
- Seedance 返回远程视频 URL 时,前端会尽量下载为本地 Blob 持久化;如果因 CORS 或网络限制无法下载,会保留远程 URL,后续是否可播放取决于上游 URL 的有效期。
|
||||
- 画布更适合桌面端使用,移动端触控体验还未系统完善。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 "删除选中";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user