mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +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) {
|
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
codexApp ||= await CodexAppClient.start(emit);
|
||||||
|
await loadCodexThread(emit, threadId, cwd, false);
|
||||||
const thread = await codexApp.resumeThread(threadId, cwd);
|
const thread = await codexApp.resumeThread(threadId, cwd);
|
||||||
|
assertThreadWorkspace(thread, cwd);
|
||||||
codexThreadId = String(field(thread, "id") || threadId);
|
codexThreadId = String(field(thread, "id") || threadId);
|
||||||
return { thread, messages: threadMessages(thread) };
|
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);
|
codexApp ||= await CodexAppClient.start(emit);
|
||||||
const result = await codexApp.listThreads({
|
const result = await codexApp.listThreads({
|
||||||
limit: options.limit || 40,
|
limit: options.limit || 40,
|
||||||
sortKey: "updated_at",
|
sortKey: "updated_at",
|
||||||
sortDirection: "desc",
|
sortDirection: "desc",
|
||||||
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
||||||
...(options.all ? {} : { cwd: options.cwd }),
|
cwd: options.cwd,
|
||||||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
...(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 };
|
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readCodexThread(emit: AgentEmit, threadId: string) {
|
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
||||||
const result = await codexApp.readThread(threadId);
|
|
||||||
const thread = field(result, "thread") || {};
|
|
||||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
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);
|
codexApp ||= await CodexAppClient.start(emit);
|
||||||
|
await loadCodexThread(emit, threadId, cwd, false);
|
||||||
await codexApp.archiveThread(threadId);
|
await codexApp.archiveThread(threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +98,11 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
|||||||
|
|
||||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
|
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
|
||||||
if (options.threadId) {
|
if (options.threadId) {
|
||||||
if (codexThreadId !== options.threadId) {
|
const result = await app.readThread(options.threadId, false);
|
||||||
const thread = await app.resumeThread(options.threadId, options.cwd);
|
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
||||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
const thread = await app.resumeThread(options.threadId, options.cwd);
|
||||||
}
|
assertThreadWorkspace(thread, options.cwd);
|
||||||
|
codexThreadId = String(field(thread, "id") || options.threadId);
|
||||||
return codexThreadId;
|
return codexThreadId;
|
||||||
}
|
}
|
||||||
if (!codexThreadId) {
|
if (!codexThreadId) {
|
||||||
@@ -155,8 +161,8 @@ class CodexAppClient {
|
|||||||
return this.request("thread/list", params);
|
return this.request("thread/list", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
readThread(threadId: string) {
|
readThread(threadId: string, includeTurns = true) {
|
||||||
return this.request("thread/read", { threadId, includeTurns: true });
|
return this.request("thread/read", { threadId, includeTurns });
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveThread(threadId: string) {
|
archiveThread(threadId: string) {
|
||||||
@@ -291,6 +297,24 @@ function normalizeCodexNotification(method: string, params: Json): AgentEvent |
|
|||||||
return null;
|
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) {
|
function normalizeItem(item: unknown) {
|
||||||
const value = item && typeof item === "object" ? { ...(item as Json) } : {};
|
const value = item && typeof item === "object" ? { ...(item as Json) } : {};
|
||||||
if (value.type === "agentMessage") value.type = "agent_message";
|
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 { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js";
|
||||||
import { CanvasSession } from "./canvas-session.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";
|
import type { AgentAttachment } from "./types.js";
|
||||||
|
|
||||||
export function startHttpServer() {
|
export function startHttpServer() {
|
||||||
@@ -44,7 +44,7 @@ export function startHttpServer() {
|
|||||||
});
|
});
|
||||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||||
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
|
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 });
|
res.json({ ok: true, workspace, ...result });
|
||||||
}));
|
}));
|
||||||
app.post("/agent/codex/threads/new", route(async (req, res) => {
|
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: [] });
|
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
||||||
}));
|
}));
|
||||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||||
|
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
|
||||||
const threadId = routeParam(req.params.threadId);
|
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) => {
|
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
||||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
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) => {
|
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
||||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||||
const threadId = routeParam(req.params.threadId);
|
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 });
|
if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}));
|
}));
|
||||||
@@ -81,6 +82,7 @@ export function startHttpServer() {
|
|||||||
threadId = String((thread as Record<string, unknown>).id || "");
|
threadId = String((thread as Record<string, unknown>).id || "");
|
||||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||||
} else if (threadId !== workspace.activeThreadId) {
|
} else if (threadId !== workspace.activeThreadId) {
|
||||||
|
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||||
}
|
}
|
||||||
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ description: 画布常用鼠标与键盘操作说明
|
|||||||
|
|
||||||
- 拖入图片文件:上传图片到画布。
|
- 拖入图片文件:上传图片到画布。
|
||||||
- 导入图片按钮:从本地选择图片。
|
- 导入图片按钮:从本地选择图片。
|
||||||
- 素材库或我的素材:选择素材后插入画布。
|
- 我的素材:选择素材后插入画布。
|
||||||
|
|
||||||
## 撤销和重做范围
|
## 撤销和重做范围
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ description: 当前项目已实现的主要功能
|
|||||||
|
|
||||||
目前画布中有三类节点:
|
目前画布中有三类节点:
|
||||||
|
|
||||||
- 图片节点:展示上传图片、生成图片或素材库图片。
|
- 图片节点:展示上传图片、生成图片或我的素材图片。
|
||||||
- 文本节点:保存提示词、说明文案、AI 文字回答等文本内容。
|
- 文本节点:保存提示词、说明文案、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 兼容接口;只适合个人或可信环境使用。
|
- AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合个人或可信环境使用。
|
||||||
- 公共素材库暂未接入持久化后端。
|
|
||||||
- Seedance 本地参考视频/音频更建议使用公网可访问 URL;上游是否接受前端传入的本地数据取决于具体兼容接口。
|
- Seedance 本地参考视频/音频更建议使用公网可访问 URL;上游是否接受前端传入的本地数据取决于具体兼容接口。
|
||||||
- Seedance 返回远程视频 URL 时,前端会尽量下载为本地 Blob 持久化;如果因 CORS 或网络限制无法下载,会保留远程 URL,后续是否可播放取决于上游 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 { CanvasNode } from "../components/canvas-node";
|
||||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "../components/canvas-node-prompt-panel";
|
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "../components/canvas-node-prompt-panel";
|
||||||
import { CanvasToolbar } from "../components/canvas-toolbar";
|
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 { CanvasZoomControls } from "../components/canvas-zoom-controls";
|
||||||
import { useCanvasStore } from "../stores/use-canvas-store";
|
import { useCanvasStore } from "../stores/use-canvas-store";
|
||||||
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
|
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
|
||||||
@@ -275,7 +275,6 @@ function InfiniteCanvasPage() {
|
|||||||
const [showImageInfo, setShowImageInfo] = useState(false);
|
const [showImageInfo, setShowImageInfo] = useState(false);
|
||||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||||
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
||||||
const [assetPickerTab, setAssetPickerTab] = useState<AssetPickerTab>("my-assets");
|
|
||||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||||
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
||||||
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
|
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
|
||||||
@@ -2567,12 +2566,7 @@ function InfiniteCanvasPage() {
|
|||||||
onDeselect={deselectCanvas}
|
onDeselect={deselectCanvas}
|
||||||
onBackgroundModeChange={setBackgroundMode}
|
onBackgroundModeChange={setBackgroundMode}
|
||||||
onShowImageInfoChange={setShowImageInfo}
|
onShowImageInfoChange={setShowImageInfo}
|
||||||
onOpenAssetLibrary={() => {
|
|
||||||
setAssetPickerTab("library");
|
|
||||||
setAssetPickerOpen(true);
|
|
||||||
}}
|
|
||||||
onOpenMyAssets={() => {
|
onOpenMyAssets={() => {
|
||||||
setAssetPickerTab("my-assets");
|
|
||||||
setAssetPickerOpen(true);
|
setAssetPickerOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -2654,7 +2648,7 @@ function InfiniteCanvasPage() {
|
|||||||
<p className="text-sm opacity-60">这会删除当前画布上的所有节点和连线。</p>
|
<p className="text-sm opacity-60">这会删除当前画布上的所有节点和连线。</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<AssetPickerModal open={assetPickerOpen} defaultTab={assetPickerTab} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
|
<AssetPickerModal open={assetPickerOpen} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
|
||||||
</section>
|
</section>
|
||||||
{assistantMounted ? (
|
{assistantMounted ? (
|
||||||
<CanvasAssistantPanel
|
<CanvasAssistantPanel
|
||||||
|
|||||||
@@ -1,43 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { Empty, Input, Modal, Pagination, Tag } from "antd";
|
||||||
import { App, Empty, Input, Modal, Pagination, Spin, Tabs, Tag } from "antd";
|
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
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 };
|
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 = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
defaultTab?: AssetPickerTab;
|
|
||||||
onInsert: (payload: InsertAssetPayload) => void;
|
onInsert: (payload: InsertAssetPayload) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
|
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) setActiveTab(defaultTab);
|
|
||||||
}, [open, defaultTab]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
||||||
<Tabs
|
<MyAssetsTab onInsert={onInsert} />
|
||||||
activeKey={activeTab}
|
|
||||||
onChange={(key) => setActiveTab(key as AssetPickerTab)}
|
|
||||||
items={[
|
|
||||||
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
|
|
||||||
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,104 +32,12 @@ const kindOptions = [
|
|||||||
{ label: "视频", value: "video" },
|
{ label: "视频", value: "video" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => 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 }) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
onClick={onClick}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
{cover ? (
|
{cover ? (
|
||||||
<img src={cover} alt={title} className="aspect-[4/3] w-full object-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>
|
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : "文本"}</Tag>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
</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 }) {
|
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||||
const assets = useAssetStore((state) => state.assets);
|
const assets = useAssetStore((state) => state.assets);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
|||||||
});
|
});
|
||||||
const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId;
|
const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId;
|
||||||
if (nextThreadId && !current.messages.length) {
|
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 || []) });
|
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Button, Segmented, Switch } from "antd";
|
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 { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
@@ -26,7 +26,6 @@ export function CanvasToolbar({
|
|||||||
onDeselect,
|
onDeselect,
|
||||||
onBackgroundModeChange,
|
onBackgroundModeChange,
|
||||||
onShowImageInfoChange,
|
onShowImageInfoChange,
|
||||||
onOpenAssetLibrary,
|
|
||||||
onOpenMyAssets,
|
onOpenMyAssets,
|
||||||
}: {
|
}: {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
@@ -47,7 +46,6 @@ export function CanvasToolbar({
|
|||||||
onDeselect: () => void;
|
onDeselect: () => void;
|
||||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||||
onShowImageInfoChange: (show: boolean) => void;
|
onShowImageInfoChange: (show: boolean) => void;
|
||||||
onOpenAssetLibrary: () => void;
|
|
||||||
onOpenMyAssets: () => void;
|
onOpenMyAssets: () => void;
|
||||||
}) {
|
}) {
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -96,9 +94,6 @@ export function CanvasToolbar({
|
|||||||
<Upload className="size-4.5" />
|
<Upload className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<Divider theme={theme} />
|
<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}>
|
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
|
||||||
<FolderOpen className="size-4.5" />
|
<FolderOpen className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -287,7 +282,6 @@ function toolLabel(id: string) {
|
|||||||
if (id === "tool-audio") return "音频";
|
if (id === "tool-audio") return "音频";
|
||||||
if (id === "tool-config") return "生成配置";
|
if (id === "tool-config") return "生成配置";
|
||||||
if (id === "tool-upload") return "上传素材";
|
if (id === "tool-upload") return "上传素材";
|
||||||
if (id === "tool-library") return "素材库";
|
|
||||||
if (id === "tool-assets") return "我的素材";
|
if (id === "tool-assets") return "我的素材";
|
||||||
if (id === "tool-style") return "画布外观";
|
if (id === "tool-style") return "画布外观";
|
||||||
if (id === "tool-delete") 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