From e31948197664705f8be83ea9a03df916841211b9 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 22 May 2026 23:19:25 +0800 Subject: [PATCH] =?UTF-8?q?style(code):=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BC=A9=E8=BF=9B=E5=92=8C=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一调整 admin.ts 文件中的接口定义缩进格式 - 优化 animated-theme-toggler.tsx 组件的代码结构和缩进 - 规范 app-config-modal.tsx 中 JSX 元素的嵌套格式 - 整理 app-providers.tsx 中的组件层级缩进 - 标准化 app-theme.ts 中的对象属性缩进格式 --- web/.editorconfig | 10 + web/.prettierignore | 14 + web/.prettierrc.json | 15 + web/bun.lock | 3 + web/components.json | 50 +- web/next.config.ts | 24 +- web/package.json | 87 +- web/postcss.config.mjs | 6 +- web/src/app/(admin)/admin/assets/page.tsx | 367 +- .../(admin)/admin/assets/use-admin-assets.ts | 144 +- web/src/app/(admin)/admin/layout.tsx | 132 +- web/src/app/(admin)/admin/page.tsx | 2 +- web/src/app/(admin)/admin/prompts/page.tsx | 461 +- .../admin/prompts/use-admin-prompts.ts | 218 +- web/src/app/(admin)/admin/settings/page.tsx | 1113 ++-- web/src/app/(user)/asset-library/page.tsx | 436 +- web/src/app/(user)/assets/page.tsx | 762 +-- .../(user)/canvas/[id]/canvas-client-page.tsx | 4558 +++++++++-------- web/src/app/(user)/canvas/[id]/page.tsx | 2 +- .../canvas/components/asset-picker-modal.tsx | 427 +- .../components/canvas-assistant-panel.tsx | 1057 ++-- .../components/canvas-config-node-panel.tsx | 467 +- .../canvas/components/canvas-connections.tsx | 106 +- .../canvas/components/canvas-context-menu.tsx | 83 +- .../canvas-delete-projects-dialog.tsx | 51 +- .../canvas-image-settings-popover.tsx | 312 +- .../canvas/components/canvas-mini-map.tsx | 256 +- .../components/canvas-node-angle-dialog.tsx | 140 +- .../components/canvas-node-crop-dialog.tsx | 240 +- .../components/canvas-node-generation.ts | 128 +- .../components/canvas-node-hover-toolbar.tsx | 324 +- .../components/canvas-node-prompt-panel.tsx | 174 +- .../(user)/canvas/components/canvas-node.tsx | 1070 ++-- .../canvas/components/canvas-project-card.tsx | 135 +- .../components/canvas-prompt-library.tsx | 27 +- .../canvas/components/canvas-size-picker.tsx | 98 +- .../canvas/components/canvas-toolbar.tsx | 472 +- .../components/canvas-zoom-controls.tsx | 119 +- .../canvas/components/infinite-canvas.tsx | 376 +- web/src/app/(user)/canvas/constants.ts | 40 +- web/src/app/(user)/canvas/page.tsx | 142 +- .../(user)/canvas/stores/use-canvas-store.ts | 204 +- .../canvas/stores/use-canvas-ui-store.ts | 40 +- web/src/app/(user)/canvas/types.ts | 151 +- .../(user)/canvas/utils/canvas-image-data.ts | 128 +- web/src/app/(user)/image/page.tsx | 1160 +++-- web/src/app/(user)/layout.tsx | 12 +- web/src/app/(user)/login/page.tsx | 108 +- web/src/app/(user)/page.tsx | 214 +- web/src/app/(user)/prompts/page.tsx | 186 +- web/src/app/api/[...path]/route.ts | 62 +- web/src/app/globals.css | 751 +-- web/src/app/layout.tsx | 51 +- web/src/app/not-found.tsx | 49 +- .../components/image-generation-pending.tsx | 62 +- .../components/layout/app-config-modal.tsx | 218 +- web/src/components/layout/app-providers.tsx | 46 +- web/src/components/layout/app-top-nav.tsx | 190 +- .../components/layout/client-root-init.tsx | 22 +- web/src/components/layout/github-link.tsx | 30 +- .../components/layout/mobile-nav-drawer.tsx | 56 +- .../components/layout/user-status-actions.tsx | 136 +- .../layout/version-release-modal.tsx | 147 +- web/src/components/model-picker.module.css | 22 +- web/src/components/model-picker.tsx | 87 +- web/src/components/prompts/prompt-card.tsx | 78 +- .../prompts/prompt-detail-dialog.tsx | 64 +- .../prompts/prompt-select-dialog.tsx | 134 +- web/src/components/prompts/use-prompt-list.ts | 30 +- .../components/ui/animated-theme-toggler.tsx | 403 +- web/src/components/ui/dia-text-reveal.tsx | 415 +- web/src/constant/navigation-tools.ts | 40 +- web/src/hooks/use-copy-text.ts | 10 +- web/src/hooks/use-version-check.ts | 140 +- web/src/lib/app-theme.ts | 130 +- web/src/lib/canvas-theme.ts | 108 +- web/src/lib/image-utils.ts | 86 +- web/src/lib/localforage-storage.ts | 52 +- web/src/lib/release.ts | 38 +- web/src/lib/utils.ts | 6 +- web/src/services/api/admin.ts | 140 +- web/src/services/api/assets.ts | 40 +- web/src/services/api/auth.ts | 24 +- web/src/services/api/image.ts | 321 +- web/src/services/api/prompts.ts | 49 +- web/src/services/api/request.ts | 110 +- web/src/services/image-storage.ts | 94 +- web/src/stores/use-asset-store.ts | 133 +- web/src/stores/use-config-store.ts | 178 +- web/src/stores/use-theme-store.ts | 18 +- web/src/stores/use-user-store.ts | 132 +- web/src/types/image.ts | 12 +- web/tsconfig.json | 65 +- 93 files changed, 11406 insertions(+), 10424 deletions(-) create mode 100644 web/.editorconfig create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc.json diff --git a/web/.editorconfig b/web/.editorconfig new file mode 100644 index 0000000..ef8973c --- /dev/null +++ b/web/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +max_line_length = 255 +trim_trailing_whitespace = true diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..fa9e40a --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,14 @@ +.next +node_modules +dist +build +coverage +bun.lock +*.log +*.ico +*.png +*.jpg +*.jpeg +*.gif +*.webp +*.svg diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..2780973 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "tabWidth": 4, + "useTabs": false, + "printWidth": 255, + "singleQuote": false, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "semi": true, + "trailingComma": "all", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "embeddedLanguageFormatting": "auto", + "endOfLine": "lf" +} diff --git a/web/bun.lock b/web/bun.lock index fefccbf..2342673 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -35,6 +35,7 @@ "@types/node": "^20", "@types/react": "19.1.12", "@types/react-dom": "19.1.9", + "prettier": "^3.8.3", "typescript": "^5", }, }, @@ -1164,6 +1165,8 @@ "powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], diff --git a/web/components.json b/web/components.json index c5ebd12..bf6a4b5 100644 --- a/web/components.json +++ b/web/components.json @@ -1,27 +1,27 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "radix-nova", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": { - "@magicui": "https://magicui.design/r/{name}" - } + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": { + "@magicui": "https://magicui.design/r/{name}" + } } diff --git a/web/next.config.ts b/web/next.config.ts index de2b1df..708701f 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -10,17 +10,17 @@ const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8"); export default function nextConfig(phase: string): NextConfig { - const isDev = phase === PHASE_DEVELOPMENT_SERVER; - const releases = parseChangelog(localChangelog); + const isDev = phase === PHASE_DEVELOPMENT_SERVER; + const releases = parseChangelog(localChangelog); - return { - allowedDevOrigins: isDev ? ["*.*.*.*"] : [], - typescript: { - ignoreBuildErrors: true, - }, - env: { - NEXT_PUBLIC_APP_VERSION: localVersion, - NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases), - }, - }; + return { + allowedDevOrigins: isDev ? ["*.*.*.*"] : [], + typescript: { + ignoreBuildErrors: true, + }, + env: { + NEXT_PUBLIC_APP_VERSION: localVersion, + NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases), + }, + }; } diff --git a/web/package.json b/web/package.json index 874cb4c..0c50c76 100644 --- a/web/package.json +++ b/web/package.json @@ -1,44 +1,47 @@ { - "name": "infinite-canvas", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "next dev --webpack -H 0.0.0.0 -p 3000", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "@ant-design/icons": "^6.1.1", - "@ant-design/nextjs-registry": "^1.3.0", - "@ant-design/pro-components": "3.0.0-beta.3", - "@codemirror/lang-json": "^6.0.2", - "@tanstack/react-query": "^5.100.9", - "@uiw/react-codemirror": "^4.25.9", - "antd": "^6.4.2", - "axios": "^1.16.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "copy-to-clipboard": "^4.0.2", - "localforage": "^1.10.0", - "lucide-react": "^1.16.0", - "motion": "^12.38.0", - "nanoid": "^5.1.11", - "next": "16.2.3", - "radix-ui": "^1.4.3", - "react": "19.2.5", - "react-dom": "19.2.5", - "shadcn": "^4.7.0", - "tailwind-merge": "^3.6.0", - "tailwindcss": "^4", - "tw-animate-css": "^1.4.0", - "zustand": "^5.0.12" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "19.1.12", - "@types/react-dom": "19.1.9", - "typescript": "^5" - } + "name": "infinite-canvas", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --webpack -H 0.0.0.0 -p 3000", + "build": "next build", + "start": "next start", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "dependencies": { + "@ant-design/icons": "^6.1.1", + "@ant-design/nextjs-registry": "^1.3.0", + "@ant-design/pro-components": "3.0.0-beta.3", + "@codemirror/lang-json": "^6.0.2", + "@tanstack/react-query": "^5.100.9", + "@uiw/react-codemirror": "^4.25.9", + "antd": "^6.4.2", + "axios": "^1.16.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "copy-to-clipboard": "^4.0.2", + "localforage": "^1.10.0", + "lucide-react": "^1.16.0", + "motion": "^12.38.0", + "nanoid": "^5.1.11", + "next": "16.2.3", + "radix-ui": "^1.4.3", + "react": "19.2.5", + "react-dom": "19.2.5", + "shadcn": "^4.7.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "prettier": "^3.8.3", + "typescript": "^5" + } } diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs index 61e3684..148a80b 100644 --- a/web/postcss.config.mjs +++ b/web/postcss.config.mjs @@ -1,7 +1,7 @@ const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + "@tailwindcss/postcss": {}, + }, }; export default config; diff --git a/web/src/app/(admin)/admin/assets/page.tsx b/web/src/app/(admin)/admin/assets/page.tsx index 0641004..5fdfefa 100644 --- a/web/src/app/(admin)/admin/assets/page.tsx +++ b/web/src/app/(admin)/admin/assets/page.tsx @@ -12,147 +12,250 @@ import { useAdminAssets } from "./use-admin-assets"; type AssetFormValues = Partial & { tagText?: string }; const typeOptions = [ - { label: "全部类型", value: "" }, - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, + { label: "全部类型", value: "" }, + { label: "文本", value: "text" }, + { label: "图片", value: "image" }, ]; const editTypeOptions = typeOptions.slice(1); export default function AdminAssetsPage() { - const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets(); - const copyText = useCopyText(); - const [form] = Form.useForm(); - const [editingAsset, setEditingAsset] = useState | null>(null); - const [detailAsset, setDetailAsset] = useState(null); - const [deletingAsset, setDeletingAsset] = useState(null); - const formType = Form.useWatch("type", form) || editingAsset?.type || "text"; - const tagOptions = tags.map((item) => ({ label: item, value: item })); + const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets(); + const copyText = useCopyText(); + const [form] = Form.useForm(); + const [editingAsset, setEditingAsset] = useState | null>(null); + const [detailAsset, setDetailAsset] = useState(null); + const [deletingAsset, setDeletingAsset] = useState(null); + const formType = Form.useWatch("type", form) || editingAsset?.type || "text"; + const tagOptions = tags.map((item) => ({ label: item, value: item })); - useEffect(() => { - if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" }); - }, [editingAsset, form]); + useEffect(() => { + if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" }); + }, [editingAsset, form]); - const saveAsset = async () => { - const value = await form.validateFields(); - const nextType = value.type || "text"; - await saveAdminAsset({ - ...editingAsset, - ...value, - type: nextType, - coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""), - tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean), - }); - setEditingAsset(null); - }; + const saveAsset = async () => { + const value = await form.validateFields(); + const nextType = value.type || "text"; + await saveAdminAsset({ + ...editingAsset, + ...value, + type: nextType, + coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""), + tags: (value.tagText || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + }); + setEditingAsset(null); + }; - const columns: ProColumns[] = [ - { - title: "封面", - dataIndex: "coverUrl", - width: 88, - render: (_, item) => {item.title}, - }, - { - title: "标题", - dataIndex: "title", - width: 260, - render: (_, item) => setDetailAsset(item)}>{item.title}, - }, - { - title: "类型", - dataIndex: "type", - width: 84, - render: (_, item) => {item.type === "image" ? "图片" : "文本"}, - }, - { - title: "标签", - dataIndex: "tags", - width: 180, - render: (_, item) => {(item.tags || []).slice(0, 3).map((tag) => {tag})}, - }, - { - title: "分类", - dataIndex: "category", - width: 120, - render: (_, item) => {item.category || "未标注"}, - }, - { - title: "操作", - key: "actions", - width: 112, - align: "right", - render: (_, item) => ( - - - - - - - rowKey="id" - columns={columns} - dataSource={assets} - loading={isLoading} - search={false} - defaultSize="middle" - tableLayout="fixed" - cardProps={{ variant: "borderless" }} - headerTitle={素材列表{total} 条} - options={{ density: true, setting: true, reload: () => void refreshAssets() }} - toolBarRender={() => []} - pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }} - /> - - - setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> -
- - - - - - {formType === "image" ? : } -
-
- - setDetailAsset(null)} footer={}> - {detailAsset ? ( - - - {detailAsset.title} - - {detailAsset.title} - {detailAsset.type === "image" ? "图片" : "文本"}{detailAsset.category ? {detailAsset.category} : null}{(detailAsset.tags || []).map((tag) => {tag})} - + return ( +
+ + +
+ + + + } onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /> + + + + + + + + + + + + + + + + +
+
+ + rowKey="id" + columns={columns} + dataSource={assets} + loading={isLoading} + search={false} + defaultSize="middle" + tableLayout="fixed" + cardProps={{ variant: "borderless" }} + headerTitle={ + + 素材列表 + {total} 条 + + } + options={{ density: true, setting: true, reload: () => void refreshAssets() }} + toolBarRender={() => [ + , + ]} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (value) => `共 ${value} 条`, + onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)), + }} + />
- {detailAsset.description ? {detailAsset.description} : null} - - - - ) : null} - - setDeletingAsset(null)} onOk={async () => { if (!deletingAsset) return; await deleteAsset(deletingAsset.id); setDeletingAsset(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。 - -
- ); + setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> +
+ + + + + + + + + + + + + + + + {formType === "image" ? ( + + + + ) : ( + + + + )} +
+
+ + setDetailAsset(null)} footer={}> + {detailAsset ? ( + + + {detailAsset.title} + + + {detailAsset.title} + + + {detailAsset.type === "image" ? "图片" : "文本"} + {detailAsset.category ? {detailAsset.category} : null} + {(detailAsset.tags || []).map((tag) => ( + {tag} + ))} + + + + {detailAsset.description ? ( + + {detailAsset.description} + + ) : null} + + + + ) : null} + + + setDeletingAsset(null)} + onOk={async () => { + if (!deletingAsset) return; + await deleteAsset(deletingAsset.id); + setDeletingAsset(null); + }} + okText="删除" + okButtonProps={{ danger: true }} + cancelText="取消" + > + 确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。 + + + ); } diff --git a/web/src/app/(admin)/admin/assets/use-admin-assets.ts b/web/src/app/(admin)/admin/assets/use-admin-assets.ts index e6eb778..d88f413 100644 --- a/web/src/app/(admin)/admin/assets/use-admin-assets.ts +++ b/web/src/app/(admin)/admin/assets/use-admin-assets.ts @@ -10,83 +10,83 @@ import { useUserStore } from "@/stores/use-user-store"; const defaultPageSize = 10; export function useAdminAssets() { - const { message } = App.useApp(); - const queryClient = useQueryClient(); - const token = useUserStore((state) => state.token); - const clearSession = useUserStore((state) => state.clearSession); - const [keyword, setKeyword] = useState(""); - const [type, setType] = useState(""); - const [tag, setTag] = useState([]); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(defaultPageSize); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const token = useUserStore((state) => state.token); + const clearSession = useUserStore((state) => state.clearSession); + const [keyword, setKeyword] = useState(""); + const [type, setType] = useState(""); + const [tag, setTag] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); - const query = useQuery({ - queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize], - queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }), - enabled: Boolean(token), - retry: false, - }); + const query = useQuery({ + queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize], + queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }), + enabled: Boolean(token), + retry: false, + }); - const saveMutation = useMutation({ - mutationFn: (asset: Partial) => saveAdminAsset(token, asset), - onSuccess: async (_, asset) => { - await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); - message.success(asset.id ? "素材已保存" : "素材已新增"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "保存失败"); - }, - }); + const saveMutation = useMutation({ + mutationFn: (asset: Partial) => saveAdminAsset(token, asset), + onSuccess: async (_, asset) => { + await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); + message.success(asset.id ? "素材已保存" : "素材已新增"); + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : "保存失败"); + }, + }); - const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteAdminAsset(token, id), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); - message.success("素材已删除"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "删除失败"); - }, - }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteAdminAsset(token, id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); + message.success("素材已删除"); + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : "删除失败"); + }, + }); - useEffect(() => { - if (query.isError) { - const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败"; - message.error(errorMessage); - if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); - } - }, [clearSession, message, query.error, query.isError]); + useEffect(() => { + if (query.isError) { + const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败"; + message.error(errorMessage); + if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); + } + }, [clearSession, message, query.error, query.isError]); - const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => { - const queryState = { keyword, type, tag, page, pageSize, ...next }; - if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; - setKeyword(queryState.keyword); - setType(queryState.type); - setTag(queryState.tag); - setPage(queryState.page); - setPageSize(queryState.pageSize); - }; + const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => { + const queryState = { keyword, type, tag, page, pageSize, ...next }; + if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; + setKeyword(queryState.keyword); + setType(queryState.type); + setTag(queryState.tag); + setPage(queryState.page); + setPageSize(queryState.pageSize); + }; - const data = query.data; + const data = query.data; - return { - assets: data?.items || [], - tags: data?.tags || [], - keyword, - kind: type, - tag, - page, - pageSize, - total: data?.total || 0, - isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending, - searchAssets: (value = keyword) => updateFilters({ keyword: value }), - changeKind: (value: string) => updateFilters({ type: value, tag: [] }), - changeTag: (value: string[]) => updateFilters({ tag: value }), - changePage: (value: number) => updateFilters({ page: value }), - changePageSize: (value: number) => updateFilters({ pageSize: value }), - resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }), - refreshAssets: () => query.refetch(), - saveAsset: (asset: Partial) => saveMutation.mutateAsync(asset), - deleteAsset: (id: string) => deleteMutation.mutateAsync(id), - }; + return { + assets: data?.items || [], + tags: data?.tags || [], + keyword, + kind: type, + tag, + page, + pageSize, + total: data?.total || 0, + isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending, + searchAssets: (value = keyword) => updateFilters({ keyword: value }), + changeKind: (value: string) => updateFilters({ type: value, tag: [] }), + changeTag: (value: string[]) => updateFilters({ tag: value }), + changePage: (value: number) => updateFilters({ page: value }), + changePageSize: (value: number) => updateFilters({ pageSize: value }), + resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }), + refreshAssets: () => query.refetch(), + saveAsset: (asset: Partial) => saveMutation.mutateAsync(asset), + deleteAsset: (id: string) => deleteMutation.mutateAsync(id), + }; } diff --git a/web/src/app/(admin)/admin/layout.tsx b/web/src/app/(admin)/admin/layout.tsx index 9624646..8ebbbff 100644 --- a/web/src/app/(admin)/admin/layout.tsx +++ b/web/src/app/(admin)/admin/layout.tsx @@ -12,68 +12,86 @@ import { adminLayoutStyle } from "@/lib/app-theme"; import { useUserStore } from "@/stores/use-user-store"; const adminMenus = [ - { key: "/admin/prompts", icon: , label: "提示词管理" }, - { key: "/admin/assets", icon: , label: "素材库" }, - { key: "/admin/settings", icon: , label: "系统设置" }, + { key: "/admin/prompts", icon: , label: "提示词管理" }, + { key: "/admin/assets", icon: , label: "素材库" }, + { key: "/admin/settings", icon: , label: "系统设置" }, ]; export default function AdminLayout({ children }: { children: ReactNode }) { - const { token: antToken } = theme.useToken(); - const router = useRouter(); - const pathname = usePathname(); - const token = useUserStore((state) => state.token); - const user = useUserStore((state) => state.user); - const isReady = useUserStore((state) => state.isReady); - const logout = useUserStore((state) => state.clearSession); - const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; - const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; + const { token: antToken } = theme.useToken(); + const router = useRouter(); + const pathname = usePathname(); + const token = useUserStore((state) => state.token); + const user = useUserStore((state) => state.user); + const isReady = useUserStore((state) => state.isReady); + const logout = useUserStore((state) => state.clearSession); + const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; + const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; - useEffect(() => { - if (!isReady) return; - if (!token) { - router.replace("/login?redirect=/admin"); - return; - } - if (user?.role !== "admin") { - router.replace("/"); - } - }, [isReady, router, token, user?.role]); + useEffect(() => { + if (!isReady) return; + if (!token) { + router.replace("/login?redirect=/admin"); + return; + } + if (user?.role !== "admin") { + router.replace("/"); + } + }, [isReady, router, token, user?.role]); + + if (!isReady || !token || user?.role !== "admin") { + return ( +
+ +
+ ); + } - if (!isReady || !token || user?.role !== "admin") { return ( -
- -
+ + + + + + 无限画布 + + + ({ + ...item, + label: ( + + {item.label} + + ), + style: adminLayoutStyle.menuItem, + }))} + /> + + + + + + + + + {pageTitle} + + + + + + {children} + + ); - } - - return ( - - - - - 无限画布 - - ({ ...item, label: {item.label}, style: adminLayoutStyle.menuItem }))} - /> - - - - - - - - {pageTitle} - - - - - {children} - - - ); } diff --git a/web/src/app/(admin)/admin/page.tsx b/web/src/app/(admin)/admin/page.tsx index 320724e..ace3dbf 100644 --- a/web/src/app/(admin)/admin/page.tsx +++ b/web/src/app/(admin)/admin/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function AdminPage() { - redirect("/admin/assets"); + redirect("/admin/assets"); } diff --git a/web/src/app/(admin)/admin/prompts/page.tsx b/web/src/app/(admin)/admin/prompts/page.tsx index 7b11bd7..d604376 100644 --- a/web/src/app/(admin)/admin/prompts/page.tsx +++ b/web/src/app/(admin)/admin/prompts/page.tsx @@ -10,159 +10,328 @@ import type { Prompt } from "@/services/api/prompts"; import { useAdminPrompts } from "./use-admin-prompts"; export default function AdminPromptsPage() { - const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt, deletePrompts } = useAdminPrompts(); - const copyText = useCopyText(); - const [form] = Form.useForm & { tagText?: string }>(); - const [editingPrompt, setEditingPrompt] = useState | null>(null); - const [detailPrompt, setDetailPrompt] = useState(null); - const [deletingPrompt, setDeletingPrompt] = useState(null); - const [selectedPromptIds, setSelectedPromptIds] = useState([]); - const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false); - const [isSyncOpen, setIsSyncOpen] = useState(false); - const defaultCategory = categories[0]?.category || ""; - const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category; - const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))]; - const tagOptions = tags.map((item) => ({ label: item, value: item })); + const { + categories, + prompts, + tags, + keyword, + category, + tag, + page, + pageSize, + total, + isLoading, + isSyncing, + searchPrompts, + changeCategory, + changeTag, + changePage, + changePageSize, + resetFilters, + refreshPrompts, + syncCategory, + savePrompt: saveAdminPrompt, + deletePrompt, + deletePrompts, + } = useAdminPrompts(); + const copyText = useCopyText(); + const [form] = Form.useForm & { tagText?: string }>(); + const [editingPrompt, setEditingPrompt] = useState | null>(null); + const [detailPrompt, setDetailPrompt] = useState(null); + const [deletingPrompt, setDeletingPrompt] = useState(null); + const [selectedPromptIds, setSelectedPromptIds] = useState([]); + const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false); + const [isSyncOpen, setIsSyncOpen] = useState(false); + const defaultCategory = categories[0]?.category || ""; + const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category; + const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))]; + const tagOptions = tags.map((item) => ({ label: item, value: item })); - useEffect(() => { - if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" }); - }, [editingPrompt, form]); + useEffect(() => { + if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" }); + }, [editingPrompt, form]); - const savePrompt = async () => { - const value = await form.validateFields(); - await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) }); - setEditingPrompt(null); - }; + const savePrompt = async () => { + const value = await form.validateFields(); + await saveAdminPrompt({ + ...editingPrompt, + ...value, + category: value.category || defaultCategory, + tags: (value.tagText || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + }); + setEditingPrompt(null); + }; - const batchDeletePrompts = async () => { - await deletePrompts(selectedPromptIds); - setSelectedPromptIds([]); - setIsBatchDeleteOpen(false); - }; + const batchDeletePrompts = async () => { + await deletePrompts(selectedPromptIds); + setSelectedPromptIds([]); + setIsBatchDeleteOpen(false); + }; - const columns: ProColumns[] = [ - { - title: "封面", - dataIndex: "coverUrl", - width: 88, - render: (_, item) => {item.title}, - }, - { - title: "标题", - dataIndex: "title", - width: 260, - render: (_, item) => setDetailPrompt(item)}>{item.title}, - }, - { - title: "分类", - dataIndex: "category", - width: 150, - render: (_, item) => {categoryName(item.category)}, - }, - { - title: "标签", - dataIndex: "tags", - width: 180, - render: (_, item) => {(item.tags || []).slice(0, 3).map((tag) => {tag})}, - }, - { - title: "操作", - key: "actions", - width: 112, - align: "right", - render: (_, item) => ( - - - - - - - rowKey="id" - columns={columns} - dataSource={prompts} - loading={isLoading} - search={false} - defaultSize="middle" - tableLayout="fixed" - cardProps={{ variant: "borderless" }} - headerTitle={提示词列表{total} 条} - options={{ density: true, setting: true, reload: () => void refreshPrompts() }} - rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }} - toolBarRender={() => [ - , - , - , - ]} - pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }} - /> - - - setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden> -
- - - - -
-
- - setDetailPrompt(null)} footer={}> - {detailPrompt ? ( - - - {detailPrompt.title} - - {detailPrompt.title} - {categoryName(detailPrompt.category)}{(detailPrompt.tags || []).map((tag) => {tag})} - + return ( +
+ + +
+ + + + } onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /> + + + + + + + + + + + + + + + + +
+
+ + rowKey="id" + columns={columns} + dataSource={prompts} + loading={isLoading} + search={false} + defaultSize="middle" + tableLayout="fixed" + cardProps={{ variant: "borderless" }} + headerTitle={ + + 提示词列表 + {total} 条 + + } + options={{ density: true, setting: true, reload: () => void refreshPrompts() }} + rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }} + toolBarRender={() => [ + , + , + , + ]} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (value) => `共 ${value} 条`, + onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)), + }} + />
- {detailPrompt.preview ? {detailPrompt.preview} : null} - - - - {detailPrompt.githubUrl ? : null} - - - ) : null} - - !isSyncing && setIsSyncOpen(false)} mask={{ closable: !isSyncing }} footer={}> - item.remote)} - pagination={false} - columns={[ - { title: "远程源", dataIndex: "name", render: (_, item) => {item.name}{item.githubUrl ? : null} }, - { title: "", key: "sync", width: 96, align: "right", render: (_, item) => }, - ]} - /> - + setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden> +
+ + + + + + + + + + + + + +
- setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。 - + setDetailPrompt(null)} footer={}> + {detailPrompt ? ( + + + + + + {detailPrompt.title} + + + {categoryName(detailPrompt.category)} + {(detailPrompt.tags || []).map((tag) => ( + {tag} + ))} + + + + {detailPrompt.preview ? ( + + {detailPrompt.preview} + + ) : null} + + + + {detailPrompt.githubUrl ? ( + + ) : null} + + + ) : null} + - setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。 - - - ); + !isSyncing && setIsSyncOpen(false)} + mask={{ closable: !isSyncing }} + footer={ + + } + > +
item.remote)} + pagination={false} + columns={[ + { + title: "远程源", + dataIndex: "name", + render: (_, item) => ( + + {item.name} + {item.githubUrl ? ( + + + + ) : null} + + ), + }, + { + title: "", + key: "sync", + width: 96, + align: "right", + render: (_, item) => ( + + ), + }, + ]} + /> + + + setDeletingPrompt(null)} + onOk={async () => { + if (!deletingPrompt) return; + await deletePrompt(deletingPrompt.id); + setDeletingPrompt(null); + }} + okText="删除" + okButtonProps={{ danger: true }} + cancelText="取消" + > + 确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。 + + + setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> + 确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。 + + + ); } diff --git a/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts b/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts index 8193314..713a66f 100644 --- a/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts +++ b/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts @@ -11,123 +11,123 @@ import { useUserStore } from "@/stores/use-user-store"; const defaultPageSize = 10; export function useAdminPrompts() { - const { message } = App.useApp(); - const queryClient = useQueryClient(); - const token = useUserStore((state) => state.token); - const clearSession = useUserStore((state) => state.clearSession); - const [keyword, setKeyword] = useState(""); - const [category, setCategory] = useState(""); - const [tag, setTag] = useState([]); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(defaultPageSize); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const token = useUserStore((state) => state.token); + const clearSession = useUserStore((state) => state.clearSession); + const [keyword, setKeyword] = useState(""); + const [category, setCategory] = useState(""); + const [tag, setTag] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); - const categoriesQuery = useQuery({ - queryKey: ["admin", "prompt-categories", token], - queryFn: () => fetchAdminPromptCategories(token), - enabled: Boolean(token), - retry: false, - }); + const categoriesQuery = useQuery({ + queryKey: ["admin", "prompt-categories", token], + queryFn: () => fetchAdminPromptCategories(token), + enabled: Boolean(token), + retry: false, + }); - const promptsQuery = useQuery({ - queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize], - queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }), - enabled: Boolean(token), - retry: false, - }); + const promptsQuery = useQuery({ + queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize], + queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }), + enabled: Boolean(token), + retry: false, + }); - const syncMutation = useMutation({ - mutationFn: (category: string) => syncAdminPromptCategory(token, category), - onSuccess: async (categories) => { - queryClient.setQueryData(["admin", "prompt-categories", token], categories); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success("远程提示词源已同步"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "同步失败"); - }, - }); + const syncMutation = useMutation({ + mutationFn: (category: string) => syncAdminPromptCategory(token, category), + onSuccess: async (categories) => { + queryClient.setQueryData(["admin", "prompt-categories", token], categories); + await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); + message.success("远程提示词源已同步"); + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : "同步失败"); + }, + }); - const saveMutation = useMutation({ - mutationFn: (prompt: Partial) => saveAdminPrompt(token, prompt), - onSuccess: async (_, prompt) => { - await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success(prompt.id ? "提示词已保存" : "提示词已新增"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "保存失败"); - }, - }); + const saveMutation = useMutation({ + mutationFn: (prompt: Partial) => saveAdminPrompt(token, prompt), + onSuccess: async (_, prompt) => { + await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); + await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); + message.success(prompt.id ? "提示词已保存" : "提示词已新增"); + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : "保存失败"); + }, + }); - const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteAdminPrompt(token, id), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success("提示词已删除"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "删除失败"); - }, - }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteAdminPrompt(token, id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); + await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); + message.success("提示词已删除"); + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : "删除失败"); + }, + }); - const batchDeleteMutation = useMutation({ - mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success("提示词已批量删除"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "批量删除失败"); - }, - }); + const batchDeleteMutation = useMutation({ + mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); + await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); + message.success("提示词已批量删除"); + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : "批量删除失败"); + }, + }); - useEffect(() => { - const error = categoriesQuery.error || promptsQuery.error; - if (!error) return; - const errorMessage = error instanceof Error ? error.message : "读取提示词失败"; - message.error(errorMessage); - if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); - }, [categoriesQuery.error, clearSession, message, promptsQuery.error]); + useEffect(() => { + const error = categoriesQuery.error || promptsQuery.error; + if (!error) return; + const errorMessage = error instanceof Error ? error.message : "读取提示词失败"; + message.error(errorMessage); + if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); + }, [categoriesQuery.error, clearSession, message, promptsQuery.error]); - const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => { - const queryState = { keyword, category, tag, page, pageSize, ...next }; - if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; - setKeyword(queryState.keyword); - setCategory(queryState.category); - setTag(queryState.tag); - setPage(queryState.page); - setPageSize(queryState.pageSize); - }; + const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => { + const queryState = { keyword, category, tag, page, pageSize, ...next }; + if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; + setKeyword(queryState.keyword); + setCategory(queryState.category); + setTag(queryState.tag); + setPage(queryState.page); + setPageSize(queryState.pageSize); + }; - const data = promptsQuery.data; + const data = promptsQuery.data; - return { - categories: categoriesQuery.data || [], - prompts: data?.items || [], - tags: data?.tags || [], - keyword, - category, - tag, - page, - pageSize, - total: data?.total || 0, - isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending, - isSyncing: syncMutation.isPending, - syncCategory: (category: string) => syncMutation.mutateAsync(category), - searchPrompts: (value = keyword) => updateFilters({ keyword: value }), - changeCategory: (value: string) => updateFilters({ category: value, tag: [] }), - changeTag: (value: string[]) => updateFilters({ tag: value }), - changePage: (value: number) => updateFilters({ page: value }), - changePageSize: (value: number) => updateFilters({ pageSize: value }), - resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }), - refreshPrompts: async () => { - await categoriesQuery.refetch(); - await promptsQuery.refetch(); - }, - savePrompt: (prompt: Partial) => saveMutation.mutateAsync(prompt), - deletePrompt: (id: string) => deleteMutation.mutateAsync(id), - deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids), - }; + return { + categories: categoriesQuery.data || [], + prompts: data?.items || [], + tags: data?.tags || [], + keyword, + category, + tag, + page, + pageSize, + total: data?.total || 0, + isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending, + isSyncing: syncMutation.isPending, + syncCategory: (category: string) => syncMutation.mutateAsync(category), + searchPrompts: (value = keyword) => updateFilters({ keyword: value }), + changeCategory: (value: string) => updateFilters({ category: value, tag: [] }), + changeTag: (value: string[]) => updateFilters({ tag: value }), + changePage: (value: number) => updateFilters({ page: value }), + changePageSize: (value: number) => updateFilters({ pageSize: value }), + resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }), + refreshPrompts: async () => { + await categoriesQuery.refetch(); + await promptsQuery.refetch(); + }, + savePrompt: (prompt: Partial) => saveMutation.mutateAsync(prompt), + deletePrompt: (id: string) => deleteMutation.mutateAsync(id), + deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids), + }; } diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 0c48ee3..3e8cbcc 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -12,30 +12,30 @@ import { useUserStore } from "@/stores/use-user-store"; const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false }); const jsonEditorTheme = EditorView.theme({ - "&": { backgroundColor: "var(--ant-color-bg-container)", color: "var(--ant-color-text)" }, - ".cm-content": { caretColor: "var(--ant-color-text)", padding: "12px 0" }, - ".cm-line": { padding: "0 18px" }, - ".cm-gutters": { backgroundColor: "var(--ant-color-fill-quaternary)", borderRight: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" }, - ".cm-activeLine": { backgroundColor: "var(--ant-color-fill-quaternary)" }, - ".cm-activeLineGutter": { backgroundColor: "var(--ant-color-fill-quaternary)", color: "var(--ant-color-text)" }, - ".cm-cursor": { borderLeftColor: "var(--ant-color-text)" }, - ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: "var(--ant-control-item-bg-active)" }, - ".cm-foldPlaceholder": { backgroundColor: "var(--ant-color-fill-quaternary)", border: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" }, - "&.cm-focused": { outline: "none" }, + "&": { backgroundColor: "var(--ant-color-bg-container)", color: "var(--ant-color-text)" }, + ".cm-content": { caretColor: "var(--ant-color-text)", padding: "12px 0" }, + ".cm-line": { padding: "0 18px" }, + ".cm-gutters": { backgroundColor: "var(--ant-color-fill-quaternary)", borderRight: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" }, + ".cm-activeLine": { backgroundColor: "var(--ant-color-fill-quaternary)" }, + ".cm-activeLineGutter": { backgroundColor: "var(--ant-color-fill-quaternary)", color: "var(--ant-color-text)" }, + ".cm-cursor": { borderLeftColor: "var(--ant-color-text)" }, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: "var(--ant-control-item-bg-active)" }, + ".cm-foldPlaceholder": { backgroundColor: "var(--ant-color-fill-quaternary)", border: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" }, + "&.cm-focused": { outline: "none" }, }); const emptySettings: AdminSettings = { - public: { - modelChannel: { - availableModels: [], - defaultModel: "", - defaultImageModel: "", - defaultTextModel: "", - systemPrompt: "", - allowCustomChannel: true, + public: { + modelChannel: { + availableModels: [], + defaultModel: "", + defaultImageModel: "", + defaultTextModel: "", + systemPrompt: "", + allowCustomChannel: true, + }, }, - }, - private: { channels: [], promptSync: { enabled: false, cron: "0 3 * * *" } }, + private: { channels: [], promptSync: { enabled: false, cron: "0 3 * * *" } }, }; const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" }; @@ -43,522 +43,645 @@ type SettingsTabKey = "public" | "private"; type EditorMode = "visual" | "json"; export default function AdminSettingsPage() { - const token = useUserStore((state) => state.token); - const { message } = App.useApp(); - const [form] = Form.useForm(); - const [activeTab, setActiveTab] = useState("public"); - const [editorMode, setEditorMode] = useState>({ public: "visual", private: "visual" }); - const [jsonText, setJsonText] = useState>({ public: "", private: "" }); - const [channels, setChannels] = useState([]); - const [channelForm] = Form.useForm(); - const [editingChannelIndex, setEditingChannelIndex] = useState(null); - const [isChannelDrawerOpen, setIsChannelDrawerOpen] = useState(false); - const [testChannelIndex, setTestChannelIndex] = useState(null); - const [testKeyword, setTestKeyword] = useState(""); - const [selectedTestModels, setSelectedTestModels] = useState([]); - const [testingModels, setTestingModels] = useState([]); - const [testResults, setTestResults] = useState>({}); - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || []; - const channelModels = useMemo(() => collectChannelModels(channels), [channels]); - const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]); - const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]); - const activeMode = editorMode[activeTab]; - const activeJsonText = jsonText[activeTab]; - const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : ""; + const token = useUserStore((state) => state.token); + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [activeTab, setActiveTab] = useState("public"); + const [editorMode, setEditorMode] = useState>({ public: "visual", private: "visual" }); + const [jsonText, setJsonText] = useState>({ public: "", private: "" }); + const [channels, setChannels] = useState([]); + const [channelForm] = Form.useForm(); + const [editingChannelIndex, setEditingChannelIndex] = useState(null); + const [isChannelDrawerOpen, setIsChannelDrawerOpen] = useState(false); + const [testChannelIndex, setTestChannelIndex] = useState(null); + const [testKeyword, setTestKeyword] = useState(""); + const [selectedTestModels, setSelectedTestModels] = useState([]); + const [testingModels, setTestingModels] = useState([]); + const [testResults, setTestResults] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || []; + const channelModels = useMemo(() => collectChannelModels(channels), [channels]); + const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]); + const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]); + const activeMode = editorMode[activeTab]; + const activeJsonText = jsonText[activeTab]; + const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : ""; - const loadSettings = async () => { - if (!token) return; - setIsLoading(true); - try { - const data = normalizeSettings(await fetchAdminSettings(token)); - form.setFieldsValue(data); - setChannels(data.private.channels); - setJsonText({ - public: JSON.stringify(data.public, null, 2), - private: JSON.stringify(data.private, null, 2), - }); - } catch (error) { - message.error(error instanceof Error ? error.message : "读取设置失败"); - } finally { - setIsLoading(false); + const loadSettings = async () => { + if (!token) return; + setIsLoading(true); + try { + const data = normalizeSettings(await fetchAdminSettings(token)); + form.setFieldsValue(data); + setChannels(data.private.channels); + setJsonText({ + public: JSON.stringify(data.public, null, 2), + private: JSON.stringify(data.private, null, 2), + }); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取设置失败"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadSettings(); + }, [token]); + + const changeTab = (nextTab: SettingsTabKey) => { + setActiveTab(nextTab); + }; + + const saveSettings = async () => { + if (!token) return; + const values = await collectSettings(form, editorMode, jsonText, message); + if (!values) { + return; + } + setIsSaving(true); + try { + const saved = normalizeSettings(await saveAdminSettings(token, values)); + const merged = mergeChannelApiKeys(values.private.channels, saved); + form.setFieldsValue(merged); + setChannels(merged.private.channels); + setJsonText({ + public: JSON.stringify(merged.public, null, 2), + private: JSON.stringify(merged.private, null, 2), + }); + message.success("已保存"); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存失败"); + } finally { + setIsSaving(false); + } + }; + + const toggleMode = (tab: SettingsTabKey, nextMode: EditorMode) => { + if (nextMode === "json") { + setJsonText((current) => ({ + ...current, + [tab]: JSON.stringify(tab === "public" ? normalizePublicSetting(form.getFieldValue(["public"]) as Partial) : normalizePrivateSetting(form.getFieldValue(["private"]) as Partial), null, 2), + })); + setEditorMode((current) => ({ ...current, [tab]: nextMode })); + return; + } + const parsed = parseTabJson(tab, jsonText[tab]); + if (!parsed) { + message.error("JSON 格式不正确"); + return; + } + form.setFieldsValue({ [tab]: parsed } as Partial); + if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels); + setEditorMode((current) => ({ ...current, [tab]: nextMode })); + }; + + const formatJson = (tab: SettingsTabKey) => { + const parsed = parseTabJson(tab, jsonText[tab]); + if (!parsed) { + message.error("JSON 格式不正确"); + return; + } + setJsonText((current) => ({ + ...current, + [tab]: JSON.stringify(parsed, null, 2), + })); + }; + + const openChannelDrawer = (index: number | null) => { + setEditingChannelIndex(index); + setIsChannelDrawerOpen(true); + channelForm.setFieldsValue(index === null ? emptyChannel : normalizeChannel(channels[index])); + }; + + const closeChannelDrawer = () => { + setIsChannelDrawerOpen(false); + setEditingChannelIndex(null); + channelForm.resetFields(); + }; + + const saveChannel = async () => { + const channel = normalizeChannel(await channelForm.validateFields()); + const nextChannels = [...channels]; + if (editingChannelIndex === null) nextChannels.push(channel); + else nextChannels[editingChannelIndex] = channel; + await persistChannels(nextChannels); + closeChannelDrawer(); + }; + + const fetchChannelModelList = async () => { + if (!token) return; + const channel = channelForm.getFieldsValue(); + if (!channel?.baseUrl) { + message.warning("请先填写接口地址"); + return; + } + if (editingChannelIndex === null && !channel?.apiKey) { + message.warning("请先填写 API Key"); + return; + } + try { + const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) }); + channelForm.setFieldValue("models", channelModels); + message.success(`已获取 ${channelModels.length} 个模型`); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取模型失败"); + } + }; + + const openTestDialog = (index: number) => { + const channel = normalizeChannel(channels[index]); + if (!channel.baseUrl || channel.models.length === 0) { + message.warning("请先填写接口地址和至少一个模型"); + return; + } + setTestChannelIndex(index); + setTestKeyword(""); + setSelectedTestModels([]); + setTestingModels([]); + setTestResults({}); + }; + + const closeTestDialog = () => { + setTestChannelIndex(null); + setTestKeyword(""); + setSelectedTestModels([]); + setTestingModels([]); + setTestResults({}); + }; + + const testModelOnline = async (model: string) => { + if (testChannelIndex === null) return; + if (!token) return; + const channel = normalizeChannel(channels[testChannelIndex]); + setTestingModels((current) => [...current, model]); + try { + const startedAt = performance.now(); + const result = await testChannelModel(token, { index: testChannelIndex, channel, model }); + setTestResults((current) => ({ ...current, [model]: { status: "success", duration: `${((performance.now() - startedAt) / 1000).toFixed(2)}s`, message: result } })); + } catch (error) { + setTestResults((current) => ({ ...current, [model]: { status: "error", message: error instanceof Error ? error.message : "测试失败" } })); + } finally { + setTestingModels((current) => current.filter((item) => item !== model)); + } + }; + + const batchTestModels = async () => { + for (const model of selectedTestModels) { + await testModelOnline(model); + } + }; + + const testChannel = testChannelIndex === null ? null : normalizeChannel(channels[testChannelIndex]); + const testModels = (testChannel?.models || []).filter((model) => model.toLowerCase().includes(testKeyword.trim().toLowerCase())); + + async function persistChannels(nextChannels: AdminModelChannel[]) { + if (!token) return; + const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); + const nextSettings = normalizeSettings({ + ...values, + private: { ...values.private, channels: nextChannels }, + }); + const saved = normalizeSettings(await saveAdminSettings(token, nextSettings)); + const merged = mergeChannelApiKeys(nextChannels, saved); + setChannels(merged.private.channels); + form.setFieldsValue(merged); + setJsonText({ + public: JSON.stringify(merged.public, null, 2), + private: JSON.stringify(merged.private, null, 2), + }); + message.success("已保存"); } - }; - useEffect(() => { - void loadSettings(); - }, [token]); - - const changeTab = (nextTab: SettingsTabKey) => { - setActiveTab(nextTab); - }; - - const saveSettings = async () => { - if (!token) return; - const values = await collectSettings(form, editorMode, jsonText, message); - if (!values) { - return; - } - setIsSaving(true); - try { - const saved = normalizeSettings(await saveAdminSettings(token, values)); - const merged = mergeChannelApiKeys(values.private.channels, saved); - form.setFieldsValue(merged); - setChannels(merged.private.channels); - setJsonText({ - public: JSON.stringify(merged.public, null, 2), - private: JSON.stringify(merged.private, null, 2), - }); - message.success("已保存"); - } catch (error) { - message.error(error instanceof Error ? error.message : "保存失败"); - } finally { - setIsSaving(false); - } - }; - - const toggleMode = (tab: SettingsTabKey, nextMode: EditorMode) => { - if (nextMode === "json") { - setJsonText((current) => ({ - ...current, - [tab]: JSON.stringify(tab === "public" ? normalizePublicSetting(form.getFieldValue(["public"]) as Partial) : normalizePrivateSetting(form.getFieldValue(["private"]) as Partial), null, 2), - })); - setEditorMode((current) => ({ ...current, [tab]: nextMode })); - return; - } - const parsed = parseTabJson(tab, jsonText[tab]); - if (!parsed) { - message.error("JSON 格式不正确"); - return; - } - form.setFieldsValue({ [tab]: parsed } as Partial); - if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels); - setEditorMode((current) => ({ ...current, [tab]: nextMode })); - }; - - const formatJson = (tab: SettingsTabKey) => { - const parsed = parseTabJson(tab, jsonText[tab]); - if (!parsed) { - message.error("JSON 格式不正确"); - return; - } - setJsonText((current) => ({ - ...current, - [tab]: JSON.stringify(parsed, null, 2), - })); - }; - - const openChannelDrawer = (index: number | null) => { - setEditingChannelIndex(index); - setIsChannelDrawerOpen(true); - channelForm.setFieldsValue(index === null ? emptyChannel : normalizeChannel(channels[index])); - }; - - const closeChannelDrawer = () => { - setIsChannelDrawerOpen(false); - setEditingChannelIndex(null); - channelForm.resetFields(); - }; - - const saveChannel = async () => { - const channel = normalizeChannel(await channelForm.validateFields()); - const nextChannels = [...channels]; - if (editingChannelIndex === null) nextChannels.push(channel); - else nextChannels[editingChannelIndex] = channel; - await persistChannels(nextChannels); - closeChannelDrawer(); - }; - - const fetchChannelModelList = async () => { - if (!token) return; - const channel = channelForm.getFieldsValue(); - if (!channel?.baseUrl) { - message.warning("请先填写接口地址"); - return; - } - if (editingChannelIndex === null && !channel?.apiKey) { - message.warning("请先填写 API Key"); - return; - } - try { - const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) }); - channelForm.setFieldValue("models", channelModels); - message.success(`已获取 ${channelModels.length} 个模型`); - } catch (error) { - message.error(error instanceof Error ? error.message : "读取模型失败"); - } - }; - - const openTestDialog = (index: number) => { - const channel = normalizeChannel(channels[index]); - if (!channel.baseUrl || channel.models.length === 0) { - message.warning("请先填写接口地址和至少一个模型"); - return; - } - setTestChannelIndex(index); - setTestKeyword(""); - setSelectedTestModels([]); - setTestingModels([]); - setTestResults({}); - }; - - const closeTestDialog = () => { - setTestChannelIndex(null); - setTestKeyword(""); - setSelectedTestModels([]); - setTestingModels([]); - setTestResults({}); - }; - - const testModelOnline = async (model: string) => { - if (testChannelIndex === null) return; - if (!token) return; - const channel = normalizeChannel(channels[testChannelIndex]); - setTestingModels((current) => [...current, model]); - try { - const startedAt = performance.now(); - const result = await testChannelModel(token, { index: testChannelIndex, channel, model }); - setTestResults((current) => ({ ...current, [model]: { status: "success", duration: `${((performance.now() - startedAt) / 1000).toFixed(2)}s`, message: result } })); - } catch (error) { - setTestResults((current) => ({ ...current, [model]: { status: "error", message: error instanceof Error ? error.message : "测试失败" } })); - } finally { - setTestingModels((current) => current.filter((item) => item !== model)); - } - }; - - const batchTestModels = async () => { - for (const model of selectedTestModels) { - await testModelOnline(model); - } - }; - - const testChannel = testChannelIndex === null ? null : normalizeChannel(channels[testChannelIndex]); - const testModels = (testChannel?.models || []).filter((model) => model.toLowerCase().includes(testKeyword.trim().toLowerCase())); - - async function persistChannels(nextChannels: AdminModelChannel[]) { - if (!token) return; - const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); - const nextSettings = normalizeSettings({ - ...values, - private: { ...values.private, channels: nextChannels }, - }); - const saved = normalizeSettings(await saveAdminSettings(token, nextSettings)); - const merged = mergeChannelApiKeys(nextChannels, saved); - setChannels(merged.private.channels); - form.setFieldsValue(merged); - setJsonText({ - public: JSON.stringify(merged.public, null, 2), - private: JSON.stringify(merged.private, null, 2), - }); - message.success("已保存"); - } - - return ( -
- - - - changeTab(key as SettingsTabKey)} - items={[ - { key: "public", label: "公开配置(对外暴露)" }, - { key: "private", label: "私有配置(不会对外暴露)" }, - ]} - /> - - - - - - - - - - toggleMode(activeTab, value as EditorMode)} - options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]} - /> - {activeMode === "json" ? ( - - {jsonError ? {jsonError} : }>JSON 格式正确} - - - ) : ( - {activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"} - )} - - - {activeTab === "public" ? ( - activeMode === "visual" ? ( -
- -
({ label: item, value: item }))} /> - ({ label: item, value: item }))} /> - - - - - ) : ( -
- setJsonText((current) => ({ ...current, public: value }))} - style={{ fontSize: 13 }} - /> -
- ) - ) : activeMode === "visual" ? ( - - - - - - - - - - -
value || "未命名渠道" }, - { title: "协议", dataIndex: "protocol", width: 96, render: (value) => {value || "openai"} }, - { title: "状态", dataIndex: "enabled", width: 96, render: (value) => {value ? "已启用" : "已停用"} }, - { title: "模型", dataIndex: "models", render: (value: string[]) => {modelSummary(value || [])} }, - { title: "权重", dataIndex: "weight", width: 88 }, - { - title: "操作", - key: "actions", - width: 220, - align: "right", - render: (_, item) => ( - - - - + - ), - }, - ]} - /> - - - ) : ( -
- setJsonText((current) => ({ ...current, private: value }))} - style={{ fontSize: 13 }} - /> -
- )} - - } destroyOnHidden> -
- -
- - - - - -
({ model }))} - rowSelection={{ - selectedRowKeys: selectedTestModels, - onChange: (keys) => setSelectedTestModels(keys.map(String)), - }} - columns={[ - { title: "模型名称", dataIndex: "model", render: (value) => {value} }, - { - title: "状态", - dataIndex: "model", - width: 260, - render: (value) => { - if (testingModels.includes(value)) return }>测试中; - const result = testResults[value]; - if (!result) return 未开始; - return result.status === "success" ? ( - - 成功 - 请求时长: {result.duration} - + + + + + + toggleMode(activeTab, value as EditorMode)} + options={[ + { label: "可视化编辑", value: "visual" }, + { label: "手动编辑 JSON", value: "json" }, + ]} + /> + {activeMode === "json" ? ( + + {jsonError ? ( + {jsonError} + ) : ( + }> + JSON 格式正确 + + )} + + + ) : ( + {activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"} + )} + + + {activeTab === "public" ? ( + activeMode === "visual" ? ( + + + + + ({ label: item, value: item }))} /> + + + + + ({ label: item, value: item }))} /> + + + + + + + + + + + + + + + ) : ( +
+ setJsonText((current) => ({ ...current, public: value }))} + style={{ fontSize: 13 }} + /> +
+ ) + ) : activeMode === "visual" ? ( + + + + + + + + + + + + + + + + + + +
value || "未命名渠道" }, + { title: "协议", dataIndex: "protocol", width: 96, render: (value) => {value || "openai"} }, + { title: "状态", dataIndex: "enabled", width: 96, render: (value) => {value ? "已启用" : "已停用"} }, + { + title: "模型", + dataIndex: "models", + render: (value: string[]) => ( + + {modelSummary(value || [])} + + ), + }, + { title: "权重", dataIndex: "weight", width: 88 }, + { + title: "操作", + key: "actions", + width: 220, + align: "right", + render: (_, item) => ( + + + + , - }, - ]} - /> - - - - - ); +
+ setJsonText((current) => ({ ...current, private: value }))} + style={{ fontSize: 13 }} + /> +
+ )} + + + + +
+ } + destroyOnHidden + > + + + + + + + + + + + + + + + + + + + + + +
({ model }))} + rowSelection={{ + selectedRowKeys: selectedTestModels, + onChange: (keys) => setSelectedTestModels(keys.map(String)), + }} + columns={[ + { title: "模型名称", dataIndex: "model", render: (value) => {value} }, + { + title: "状态", + dataIndex: "model", + width: 260, + render: (value) => { + if (testingModels.includes(value)) return }>测试中; + const result = testResults[value]; + if (!result) return 未开始; + return result.status === "success" ? ( + + 成功 + 请求时长: {result.duration} + + ) : ( + {result.message} + ); + }, + }, + { + title: "操作", + key: "actions", + width: 120, + align: "right", + render: (_, item) => ( + + ), + }, + ]} + /> + + + + + ); } function normalizeSettings(settings: Partial = {}): AdminSettings { - const privateSetting = normalizePrivateSetting(settings.private); - return { - public: { - ...normalizePublicSetting(settings.public), - }, - private: privateSetting, - }; + const privateSetting = normalizePrivateSetting(settings.private); + return { + public: { + ...normalizePublicSetting(settings.public), + }, + private: privateSetting, + }; } function normalizePublicSetting(setting: Partial = {}): AdminSettings["public"] { - return { - ...emptySettings.public, - modelChannel: { - ...emptySettings.public.modelChannel, - ...(setting.modelChannel || {}), - availableModels: setting.modelChannel?.availableModels || [], - }, - }; + return { + ...emptySettings.public, + modelChannel: { + ...emptySettings.public.modelChannel, + ...(setting.modelChannel || {}), + availableModels: setting.modelChannel?.availableModels || [], + }, + }; } function normalizePrivateSetting(setting: Partial = {}): AdminSettings["private"] { - return { - channels: (setting.channels || []).map(normalizeChannel), - promptSync: { - enabled: setting.promptSync?.enabled === true, - cron: setting.promptSync?.cron || "0 3 * * *", - }, - }; + return { + channels: (setting.channels || []).map(normalizeChannel), + promptSync: { + enabled: setting.promptSync?.enabled === true, + cron: setting.promptSync?.cron || "0 3 * * *", + }, + }; } function normalizeChannel(item: Partial = {}): AdminModelChannel { - return { - protocol: "openai", - name: item.name || "", - baseUrl: item.baseUrl || "", - apiKey: item.apiKey || "", - models: item.models || [], - weight: Math.max(1, Number(item.weight) || 1), - enabled: item.enabled !== false, - remark: item.remark || "", - }; + return { + protocol: "openai", + name: item.name || "", + baseUrl: item.baseUrl || "", + apiKey: item.apiKey || "", + models: item.models || [], + weight: Math.max(1, Number(item.weight) || 1), + enabled: item.enabled !== false, + remark: item.remark || "", + }; } function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminSettings): AdminSettings { - const channels = saved.private.channels.map((item, index) => ({ - ...item, - apiKey: currentChannels[index]?.apiKey || item.apiKey, - })); - return { - public: saved.public, - private: { ...saved.private, channels }, - }; + const channels = saved.private.channels.map((item, index) => ({ + ...item, + apiKey: currentChannels[index]?.apiKey || item.apiKey, + })); + return { + public: saved.public, + private: { ...saved.private, channels }, + }; } function collectChannelModels(channels: AdminModelChannel[]) { - return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || [])); + return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || [])); } function uniqueModels(models: string[]) { - return Array.from(new Set(models.filter(Boolean))); + return Array.from(new Set(models.filter(Boolean))); } function modelSummary(models: string[]) { - if (!models.length) return "未配置模型"; - const preview = models.slice(0, 3).join(", "); - return models.length > 3 ? `${models.length} 个模型:${preview}...` : preview; + if (!models.length) return "未配置模型"; + const preview = models.slice(0, 3).join(", "); + return models.length > 3 ? `${models.length} 个模型:${preview}...` : preview; } function parseTabJson(tab: "public", value: string): AdminSettings["public"] | null; function parseTabJson(tab: "private", value: string): AdminSettings["private"] | null; function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null; function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null { - try { - return tab === "public" ? normalizePublicSetting(JSON.parse(value) as Partial) : normalizePrivateSetting(JSON.parse(value) as Partial); - } catch { - return null; - } + try { + return tab === "public" ? normalizePublicSetting(JSON.parse(value) as Partial) : normalizePrivateSetting(JSON.parse(value) as Partial); + } catch { + return null; + } } async function collectSettings(form: any, editorMode: Record, jsonText: Record, message: { error: (value: string) => void }) { - const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); - if (editorMode.public === "json") { - const publicSetting = parseTabJson("public", jsonText.public); - if (!publicSetting) { - message.error("公开配置 JSON 格式不正确"); - return null; + const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); + if (editorMode.public === "json") { + const publicSetting = parseTabJson("public", jsonText.public); + if (!publicSetting) { + message.error("公开配置 JSON 格式不正确"); + return null; + } + values.public = publicSetting; } - values.public = publicSetting; - } - if (editorMode.private === "json") { - const privateSetting = parseTabJson("private", jsonText.private); - if (!privateSetting) { - message.error("私有配置 JSON 格式不正确"); - return null; + if (editorMode.private === "json") { + const privateSetting = parseTabJson("private", jsonText.private); + if (!privateSetting) { + message.error("私有配置 JSON 格式不正确"); + return null; + } + values.private = privateSetting; } - values.private = privateSetting; - } - return normalizeSettings(values); + return normalizeSettings(values); } function getJsonError(value: string) { - try { - JSON.parse(value); - return ""; - } catch (error) { - return error instanceof Error ? error.message : "JSON 格式不正确"; - } + try { + JSON.parse(value); + return ""; + } catch (error) { + return error instanceof Error ? error.message : "JSON 格式不正确"; + } } diff --git a/web/src/app/(user)/asset-library/page.tsx b/web/src/app/(user)/asset-library/page.tsx index a134f48..426ad59 100644 --- a/web/src/app/(user)/asset-library/page.tsx +++ b/web/src/app/(user)/asset-library/page.tsx @@ -15,200 +15,274 @@ 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([]); - const [page, setPage] = useState(1); - const [selectedAsset, setSelectedAsset] = useState(null); - const addAsset = useAssetStore((state) => state.addAsset); + const { message } = App.useApp(); + const copyText = useCopyText(); + const [keyword, setKeyword] = useState(""); + const [selectedType, setSelectedType] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const [page, setPage] = useState(1); + const [selectedAsset, setSelectedAsset] = useState(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, - }); + 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 : "获取素材库失败"); + 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 ( +
+ +
+ ); } - }, [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 ( -
- -
+
+
+
+
+

素材库

+

挑选团队素材,加入我的素材后继续编辑和使用。

+
+
+ } + value={keyword} + placeholder="按标题查询" + onChange={(event) => { + setPage(1); + setKeyword(event.target.value); + }} + /> +
+
+
+
类型
+
+ {[ + { label: "全部", value: "" }, + { label: "文本", value: "text" }, + { label: "图片", value: "image" }, + ].map((item) => ( + { + setPage(1); + setSelectedType(item.value); + }} + > + {item.label} + + ))} +
+
+
+
标签
+
+ { + setPage(1); + setSelectedTags([]); + }} + > + 全部 + + {availableTags.map((tag) => ( + { + setPage(1); + toggleTag(tag); + }} + > + {tag} + + ))} +
+
+
+
+ +
+
+ {items.map((asset) => ( + setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} /> + ))} +
+ + {!items.length ? : null} + +
+ setPage(nextPage)} /> +
+
+
+ + setSelectedAsset(null)}> + {selectedAsset ? ( +
+ {selectedAsset.coverUrl ? ( + + ) : ( +
{selectedAsset.content || "暂无封面"}
+ )} +
+ + {selectedAsset.title} + +
+ {selectedAsset.type === "image" ? "图片" : "文本"} + {selectedAsset.tags.map((tag) => ( + {tag} + ))} +
+
+
+ + 内容 + + {selectedAsset.type === "text" ? {selectedAsset.content} : {selectedAsset.url}} +
+ {selectedAsset.description ? {selectedAsset.description} : null} +
+ {selectedAsset.type === "text" ? ( + + ) : null} + {selectedAsset.type === "image" ? ( + + ) : null} + +
+
+ ) : null} +
+
); - } - - return ( -
-
-
-
-

素材库

-

挑选团队素材,加入我的素材后继续编辑和使用。

-
-
- } value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} /> -
-
-
-
类型
-
- {[ - { label: "全部", value: "" }, - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, - ].map((item) => ( - { setPage(1); setSelectedType(item.value); }}> - {item.label} - - ))} -
-
-
-
标签
-
- { setPage(1); setSelectedTags([]); }}> - 全部 - - {availableTags.map((tag) => ( - { setPage(1); toggleTag(tag); }}> - {tag} - - ))} -
-
-
-
- -
-
- {items.map((asset) => ( - setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} /> - ))} -
- - {!items.length ? : null} - -
- setPage(nextPage)} /> -
-
-
- - setSelectedAsset(null)}> - {selectedAsset ? ( -
- {selectedAsset.coverUrl ? :
{selectedAsset.content || "暂无封面"}
} -
- {selectedAsset.title} -
- {selectedAsset.type === "image" ? "图片" : "文本"} - {selectedAsset.tags.map((tag) => {tag})} -
-
-
- 内容 - {selectedAsset.type === "text" ? {selectedAsset.content} : {selectedAsset.url}} -
- {selectedAsset.description ? {selectedAsset.description} : null} -
- {selectedAsset.type === "text" ? : null} - {selectedAsset.type === "image" ? : null} - -
-
- ) : null} -
-
- ); } function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) { - const cover = asset.coverUrl; - return ( - {cover ? {asset.title} :
{asset.content || "暂无封面"}
}}> - -
- - -
-
- ); + const cover = asset.coverUrl; + return ( + + {cover ? ( + {asset.title} + ) : ( +
{asset.content || "暂无封面"}
+ )} + + } + > + +
+ + +
+
+ ); } async function remoteImageToDataUrl(url: string) { - const response = await axios.get(url, { responseType: "blob" }); - const blob = response.data as Blob; - return await blobToDataUrl(blob); + 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((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(new Error("读取图片失败")); - reader.readAsDataURL(blob); - }); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(new Error("读取图片失败")); + reader.readAsDataURL(blob); + }); } diff --git a/web/src/app/(user)/assets/page.tsx b/web/src/app/(user)/assets/page.tsx index 689eefb..c8ddc93 100644 --- a/web/src/app/(user)/assets/page.tsx +++ b/web/src/app/(user)/assets/page.tsx @@ -11,373 +11,483 @@ import { cn } from "@/lib/utils"; import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store"; type AssetFormValues = { - kind: AssetKind; - title: string; - coverUrl: string; - tags: string[]; - source?: string; - note?: string; - content?: string; + kind: AssetKind; + title: string; + coverUrl: string; + tags: string[]; + source?: string; + note?: string; + content?: string; }; type ImageDraft = ImageAsset["data"] | null; const kindOptions = [ - { label: "全部", value: "all" }, - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, + { label: "全部", value: "all" }, + { label: "文本", value: "text" }, + { label: "图片", value: "image" }, ]; export default function AssetsPage() { - const { message } = App.useApp(); - const copyText = useCopyText(); - const [form] = Form.useForm(); - const coverInputRef = useRef(null); - const imageInputRef = useRef(null); - const assets = useAssetStore((state) => state.assets); - const addAsset = useAssetStore((state) => state.addAsset); - const updateAsset = useAssetStore((state) => state.updateAsset); - const removeAsset = useAssetStore((state) => state.removeAsset); - const [keyword, setKeyword] = useState(""); - const [kindFilter, setKindFilter] = useState("all"); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [editingAsset, setEditingAsset] = useState(null); - const [isAssetOpen, setIsAssetOpen] = useState(false); - const [previewAsset, setPreviewAsset] = useState(null); - const [deletingAsset, setDeletingAsset] = useState(null); - const [formKind, setFormKind] = useState("text"); - const [imageDraft, setImageDraft] = useState(null); - const coverUrl = Form.useWatch("coverUrl", form) || ""; - const title = Form.useWatch("title", form) || ""; - const tags = Form.useWatch("tags", form) || []; - const content = Form.useWatch("content", form) || ""; - const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]); + const { message } = App.useApp(); + const copyText = useCopyText(); + const [form] = Form.useForm(); + const coverInputRef = useRef(null); + const imageInputRef = useRef(null); + const assets = useAssetStore((state) => state.assets); + const addAsset = useAssetStore((state) => state.addAsset); + const updateAsset = useAssetStore((state) => state.updateAsset); + const removeAsset = useAssetStore((state) => state.removeAsset); + const [keyword, setKeyword] = useState(""); + const [kindFilter, setKindFilter] = useState("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [editingAsset, setEditingAsset] = useState(null); + const [isAssetOpen, setIsAssetOpen] = useState(false); + const [previewAsset, setPreviewAsset] = useState(null); + const [deletingAsset, setDeletingAsset] = useState(null); + const [formKind, setFormKind] = useState("text"); + const [imageDraft, setImageDraft] = useState(null); + const coverUrl = Form.useWatch("coverUrl", form) || ""; + const title = Form.useWatch("title", form) || ""; + const tags = Form.useWatch("tags", form) || []; + const content = Form.useWatch("content", form) || ""; + const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]); - const filteredAssets = useMemo(() => { - const query = keyword.trim().toLowerCase(); - return validAssets.filter((asset) => { - if (kindFilter !== "all" && asset.kind !== kindFilter) return false; - if (!query) return true; - return assetSearchText(asset).includes(query); - }); - }, [validAssets, keyword, kindFilter]); + const filteredAssets = useMemo(() => { + const query = keyword.trim().toLowerCase(); + return validAssets.filter((asset) => { + if (kindFilter !== "all" && asset.kind !== kindFilter) return false; + if (!query) return true; + return assetSearchText(asset).includes(query); + }); + }, [validAssets, keyword, kindFilter]); - const visibleAssets = useMemo(() => { - const start = (page - 1) * pageSize; - return filteredAssets.slice(start, start + pageSize); - }, [filteredAssets, page, pageSize]); + const visibleAssets = useMemo(() => { + const start = (page - 1) * pageSize; + return filteredAssets.slice(start, start + pageSize); + }, [filteredAssets, page, pageSize]); - useEffect(() => { - const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize)); - setPage((value) => Math.min(value, maxPage)); - }, [filteredAssets.length, pageSize]); + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize)); + setPage((value) => Math.min(value, maxPage)); + }, [filteredAssets.length, pageSize]); - const openCreate = () => { - setEditingAsset(null); - setImageDraft(null); - setFormKind("text"); - form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" }); - setIsAssetOpen(true); - }; - - const openEdit = (asset: Asset) => { - setEditingAsset(asset); - setFormKind(asset.kind); - setImageDraft(asset.kind === "image" ? asset.data : null); - form.setFieldsValue({ - kind: asset.kind, - title: asset.title, - coverUrl: asset.coverUrl, - tags: asset.tags || [], - source: asset.source, - note: asset.note, - content: asset.kind === "text" ? asset.data.content : "", - }); - setIsAssetOpen(true); - }; - - const saveAsset = async () => { - const values = await form.validateFields(); - const base = { - title: values.title.trim(), - coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""), - tags: values.tags || [], - source: values.source?.trim(), - note: values.note?.trim(), - metadata: editingAsset?.metadata || { source: "manual" }, + const openCreate = () => { + setEditingAsset(null); + setImageDraft(null); + setFormKind("text"); + form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" }); + setIsAssetOpen(true); }; - if (values.kind === "text") { - const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } }; - editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); - } else { - if (!imageDraft) { - message.error("请选择图片文件"); - return; - } - const asset = { ...base, kind: "image" as const, data: imageDraft }; - editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); - } + const openEdit = (asset: Asset) => { + setEditingAsset(asset); + setFormKind(asset.kind); + setImageDraft(asset.kind === "image" ? asset.data : null); + form.setFieldsValue({ + kind: asset.kind, + title: asset.title, + coverUrl: asset.coverUrl, + tags: asset.tags || [], + source: asset.source, + note: asset.note, + content: asset.kind === "text" ? asset.data.content : "", + }); + setIsAssetOpen(true); + }; - message.success(editingAsset ? "素材已更新" : "素材已保存"); - setIsAssetOpen(false); - }; + const saveAsset = async () => { + const values = await form.validateFields(); + const base = { + title: values.title.trim(), + coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""), + tags: values.tags || [], + source: values.source?.trim(), + note: values.note?.trim(), + metadata: editingAsset?.metadata || { source: "manual" }, + }; - const readCoverFile = async (file?: File) => { - if (!file) return; - const dataUrl = await readFileAsDataUrl(file); - form.setFieldValue("coverUrl", dataUrl); - }; + if (values.kind === "text") { + const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } }; + editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); + } else { + if (!imageDraft) { + message.error("请选择图片文件"); + return; + } + const asset = { ...base, kind: "image" as const, data: imageDraft }; + editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); + } - const readImageFile = async (file?: File) => { - if (!file || !file.type.startsWith("image/")) return; - const image = await uploadImage(file); - const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType }; - setImageDraft(draft); - if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl); - if (!form.getFieldValue("title")) form.setFieldValue("title", file.name); - }; + message.success(editingAsset ? "素材已更新" : "素材已保存"); + setIsAssetOpen(false); + }; - const copyAssetText = async (asset: Asset) => { - if (asset.kind !== "text") return; - copyText(asset.data.content, "文本已复制"); - }; + const readCoverFile = async (file?: File) => { + if (!file) return; + const dataUrl = await readFileAsDataUrl(file); + form.setFieldValue("coverUrl", dataUrl); + }; - const downloadImage = (asset: Asset) => { - if (asset.kind !== "image") return; - const link = document.createElement("a"); - link.href = asset.data.dataUrl; - link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`; - link.click(); - }; + const readImageFile = async (file?: File) => { + if (!file || !file.type.startsWith("image/")) return; + const image = await uploadImage(file); + const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType }; + setImageDraft(draft); + if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl); + if (!form.getFieldValue("title")) form.setFieldValue("title", file.name); + }; - const confirmDelete = () => { - if (!deletingAsset) return; - removeAsset(deletingAsset.id); - message.success("素材已删除"); - setDeletingAsset(null); - }; + const copyAssetText = async (asset: Asset) => { + if (asset.kind !== "text") return; + copyText(asset.data.content, "文本已复制"); + }; - return ( -
-
-
-
-

我的素材

-

收藏常用文本和图片,按类型、标题和标签快速查找。

-
+ const downloadImage = (asset: Asset) => { + if (asset.kind !== "image") return; + const link = document.createElement("a"); + link.href = asset.data.dataUrl; + link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`; + link.click(); + }; -
- } value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onSearch={(value) => { setPage(1); setKeyword(value); }} /> -
+ const confirmDelete = () => { + if (!deletingAsset) return; + removeAsset(deletingAsset.id); + message.success("素材已删除"); + setDeletingAsset(null); + }; -
-
-
-
类型
-
- {kindOptions.map((option) => ( - { setPage(1); setKindFilter(option.value as AssetKind | "all"); }}> - {option.label} - - ))} + return ( +
+
+
+
+

我的素材

+

收藏常用文本和图片,按类型、标题和标签快速查找。

+
+ +
+ } + value={keyword} + placeholder="搜索标题、内容、标签或来源" + onChange={(event) => { + setPage(1); + setKeyword(event.target.value); + }} + onSearch={(value) => { + setPage(1); + setKeyword(value); + }} + /> +
+ +
+
+
+
类型
+
+ {kindOptions.map((option) => ( + { + setPage(1); + setKindFilter(option.value as AssetKind | "all"); + }} + > + {option.label} + + ))} +
+
+ +
+
-
- -
-
-
-
-
- {visibleAssets.map((asset) => ( - setPreviewAsset(asset)} - onEdit={() => openEdit(asset)} - onCopy={copyAssetText} - onDownload={downloadImage} - onDelete={() => setDeletingAsset(asset)} - /> - ))} -
+
+
+ {visibleAssets.map((asset) => ( + setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} /> + ))} +
- {!visibleAssets.length ? : null} + {!visibleAssets.length ? : null} -
- { - setPage(nextPage); - setPageSize(nextPageSize); - }} - /> -
-
-
- - setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> -
- - - - - - - - - - - - - - - - -
- {formKind === "text" ? ( - - - - ) : ( - -
- - {imageDraft ? {imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)} : 未选择图片} +
+ { + setPage(nextPage); + setPageSize(nextPageSize); + }} + /> +
-
- )} - -
- 预览 -
- {coverUrl || imageDraft?.dataUrl ? :
{content || "暂无封面"}
} -
- {title || "未命名素材"} -
- {tags.length ? tags.map((tag) => {tag}) : 未打标签} + + + setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> +
+
+ + + + + + + + + + + + + + + +
+ {formKind === "text" ? ( + + + + ) : ( + +
+ + {imageDraft ? ( + + {imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)} + + ) : ( + + 未选择图片 + + )} +
+
+ )} + +
+ 预览 +
+ {coverUrl || imageDraft?.dataUrl ? ( + + ) : ( +
{content || "暂无封面"}
+ )} +
+ + {title || "未命名素材"} + +
+ {tags.length ? ( + tags.map((tag) => ( + + {tag} + + )) + ) : ( + 未打标签 + )} +
+
+
+
-
-
-
+ { + void readCoverFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + { + void readImageFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> +
+ + setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} /> + + setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> + 确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。 +
- { - void readCoverFile(event.target.files?.[0]); - event.target.value = ""; - }} /> - { - void readImageFile(event.target.files?.[0]); - event.target.value = ""; - }} /> - - - setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} /> - - setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。 - - - ); + ); } function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) { - const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : ""); - const summary = assetSummary(asset); - return ( - - {cover ? {asset.title} :
{asset.kind === "text" ? asset.data.content : "暂无封面"}
} - - } - > - + } + > + +
+ + + {asset.kind === "text" ? ( + + ) : null} + {asset.kind === "image" ? ( + + ) : null} +
- {asset.kind === "image" ? "图片" : "文本"} - - - {summary} - -
- {(asset.tags || []).slice(0, 3).map((tag) => {tag})} - {!asset.tags?.length ? 无标签 : null} -
- - -
- - - {asset.kind === "text" ? : null} - {asset.kind === "image" ? : null} - -
-
- ); + + ); } function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) { - const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : ""; - return ( - - {asset ? ( -
- {cover ? :
{asset.kind === "text" ? asset.data.content : "暂无封面"}
} -
- {asset.title} - - {asset.kind === "image" ? "图片" : "文本"} - {(asset.tags || []).map((tag) => {tag})} - -
-
- 内容 - {asset.kind === "text" ? {asset.data.content} : {asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}} -
- {asset.note ?
备注{asset.note}
: null} - - {asset.kind === "text" ? : null} - {asset.kind === "image" ? : null} - -
- ) : null} -
- ); + const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : ""; + return ( + + {asset ? ( +
+ {cover ? ( + + ) : ( +
{asset.kind === "text" ? asset.data.content : "暂无封面"}
+ )} +
+ + {asset.title} + + + {asset.kind === "image" ? "图片" : "文本"} + {(asset.tags || []).map((tag) => ( + {tag} + ))} + +
+
+ + 内容 + + {asset.kind === "text" ? ( + {asset.data.content} + ) : ( + + {asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType} + + )} +
+ {asset.note ? ( +
+ 备注 + {asset.note} +
+ ) : null} + + {asset.kind === "text" ? ( + + ) : null} + {asset.kind === "image" ? ( + + ) : null} + +
+ ) : null} +
+ ); } function assetSummary(asset: Asset) { - if (asset.kind === "text") return asset.data.content; - return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`; + if (asset.kind === "text") return asset.data.content; + return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`; } function assetSearchText(asset: Asset) { - return [ - asset.title, - asset.source || "", - asset.note || "", - (asset.tags || []).join(" "), - asset.kind === "text" ? asset.data.content : asset.data.mimeType, - ].join(" ").toLowerCase(); + return [asset.title, asset.source || "", asset.note || "", (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase(); } diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 736d3af..593c094 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -33,23 +33,36 @@ import { CanvasToolbar } from "../components/canvas-toolbar"; import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal"; import { CanvasZoomControls } from "../components/canvas-zoom-controls"; import { useCanvasStore } from "../stores/use-canvas-store"; -import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type ConnectionHandle, type ContextMenuState, type Position, type SelectionBox, type ViewportTransform } from "../types"; +import { + CanvasNodeType, + type CanvasAssistantImage, + type CanvasAssistantSession, + type CanvasConnection, + type CanvasImageGenerationType, + type CanvasNodeData, + type CanvasNodeMetadata, + type ConnectionHandle, + type ContextMenuState, + type Position, + type SelectionBox, + type ViewportTransform, +} from "../types"; import type { ReferenceImage } from "@/types/image"; type CanvasClipboard = { - nodes: CanvasNodeData[]; - connections: CanvasConnection[]; + nodes: CanvasNodeData[]; + connections: CanvasConnection[]; }; type PendingConnectionCreate = { - connection: ConnectionHandle; - position: Position; + connection: ConnectionHandle; + position: Position; }; type CanvasHistoryEntry = Pick & { - chatSessions: CanvasAssistantSession[]; - activeChatId: string | null; - backgroundMode: CanvasBackgroundMode; + chatSessions: CanvasAssistantSession[]; + activeChatId: string | null; + backgroundMode: CanvasBackgroundMode; }; const UPLOADED_IMAGE_MAX_SIDE = 640; @@ -58,2385 +71,2476 @@ const NODE_STATUS_SUCCESS = "success" as const; const NODE_STATUS_ERROR = "error" as const; function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData { - const spec = getNodeSpec(type); - const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const spec = getNodeSpec(type); + const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - return { - id, - type, - title: spec.title, - position: { - x: position.x - spec.width / 2, - y: position.y - spec.height / 2, - }, - width: spec.width, - height: spec.height, - metadata: { ...spec.metadata, ...metadata }, - }; + return { + id, + type, + title: spec.title, + position: { + x: position.x - spec.width / 2, + y: position.y - spec.height / 2, + }, + width: spec.width, + height: spec.height, + metadata: { ...spec.metadata, ...metadata }, + }; } export default function CanvasPage() { - const [mounted, setMounted] = useState(false); + const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); + useEffect(() => { + setMounted(true); + }, []); - if (!mounted) return ; + if (!mounted) return ; - return ; + return ; } function CanvasRefreshShell() { - return ( -
-
+ return ( +
+
-
- ); +
+ ); } -function ConnectionCreateMenu({ - pending, - onCreate, - onClose, -}: { - pending: PendingConnectionCreate; - onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config) => void; - onClose: () => void; -}) { - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - return ( -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > -
- 引用该节点生成 - +
+
+ } title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} /> + } title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> + } title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} /> +
+
+ ); +} + +function ConnectionCreateOption({ icon, title, description, onClick }: { icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) { + return ( + - -
- } title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} /> - } title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> - } title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} /> -
- - ); -} - -function ConnectionCreateOption({ - icon, - title, - description, - onClick, -}: { - icon: React.ReactNode; - title: string; - description?: string; - onClick?: () => void; -}) { - return ( - - ); + ); } function InfiniteCanvasPage() { - const { message } = App.useApp(); - const params = useParams<{ id: string }>(); - const router = useRouter(); - const projectId = params.id; - const containerRef = useRef(null); - const imageInputRef = useRef(null); - const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null); - const clipboardRef = useRef(null); - const historyRef = useRef<{ past: CanvasHistoryEntry[]; future: CanvasHistoryEntry[] }>({ past: [], future: [] }); - const lastHistoryRef = useRef(null); - const historyCommitTimerRef = useRef | null>(null); - const viewportSaveTimerRef = useRef | null>(null); - const applyingHistoryRef = useRef(false); - const historyPausedRef = useRef(false); - const didInitialCenterRef = useRef(false); - const rafRef = useRef(null); - const toolbarHideTimerRef = useRef | null>(null); - const nodeDraggingRef = useRef(false); - const dragRef = useRef<{ - isDraggingNode: boolean; - hasMoved: boolean; - startX: number; - startY: number; - initialSelectedNodes: { id: string; x: number; y: number }[]; - }>({ - isDraggingNode: false, - hasMoved: false, - startX: 0, - startY: 0, - initialSelectedNodes: [], - }); - - const config = useConfigStore((state) => state.config); - const effectiveConfig = useEffectiveConfig(); - const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); - const openConfigDialog = useConfigStore((state) => state.openConfigDialog); - const addAsset = useAssetStore((state) => state.addAsset); - const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); - const hydrated = useCanvasStore((state) => state.hydrated); - const createProject = useCanvasStore((state) => state.createProject); - const openProject = useCanvasStore((state) => state.openProject); - const updateProject = useCanvasStore((state) => state.updateProject); - const renameProject = useCanvasStore((state) => state.renameProject); - const deleteProjects = useCanvasStore((state) => state.deleteProjects); - const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId)); - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const [nodes, setNodes] = useState([]); - const [connections, setConnections] = useState([]); - const [chatSessions, setChatSessions] = useState([]); - const [activeChatId, setActiveChatId] = useState(null); - const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); - const [size, setSize] = useState({ width: 1200, height: 720 }); - const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); - const [selectedConnectionId, setSelectedConnectionId] = useState(null); - const [hoveredNodeId, setHoveredNodeId] = useState(null); - const [connectingParams, setConnectingParams] = useState(null); - const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null); - const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null); - const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 }); - const [selectionBox, setSelectionBox] = useState(null); - const [contextMenu, setContextMenu] = useState(null); - const [runningNodeId, setRunningNodeId] = useState(null); - const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); - const [backgroundMode, setBackgroundMode] = useState("lines"); - const [clearConfirmOpen, setClearConfirmOpen] = useState(false); - const [assetPickerOpen, setAssetPickerOpen] = useState(false); - const [assetPickerTab, setAssetPickerTab] = useState("my-assets"); - const [projectLoaded, setProjectLoaded] = useState(false); - const [toolbarNodeId, setToolbarNodeId] = useState(null); - const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); - const [dialogNodeId, setDialogNodeId] = useState(null); - const [editingNodeId, setEditingNodeId] = useState(null); - const [editRequestNonce, setEditRequestNonce] = useState(0); - const [infoNodeId, setInfoNodeId] = useState(null); - const [cropNodeId, setCropNodeId] = useState(null); - const [angleNodeId, setAngleNodeId] = useState(null); - const [assistantCollapsed, setAssistantCollapsed] = useState(true); - const [assistantMounted, setAssistantMounted] = useState(false); - const [titleEditing, setTitleEditing] = useState(false); - const [titleDraft, setTitleDraft] = useState(""); - const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }); - const [collapsingBatchIds, setCollapsingBatchIds] = useState>(new Set()); - const [openingBatchIds, setOpeningBatchIds] = useState>(new Set()); - const [isNodeDragging, setIsNodeDragging] = useState(false); - - const nodesRef = useRef(nodes); - const connectionsRef = useRef(connections); - const selectedNodeIdsRef = useRef(selectedNodeIds); - const viewportRef = useRef(viewport); - const connectingParamsRef = useRef(connectingParams); - const connectionTargetNodeIdRef = useRef(connectionTargetNodeId); - const selectionBoxRef = useRef(selectionBox); - const pendingConnectionCreateRef = useRef(pendingConnectionCreate); - - const createHistoryEntry = useCallback((): CanvasHistoryEntry => ({ - nodes: nodesRef.current, - connections: connectionsRef.current, - chatSessions, - activeChatId, - backgroundMode, - }), [activeChatId, backgroundMode, chatSessions]); - - useEffect(() => { - if (!hydrated) return; - setProjectLoaded(false); - const project = openProject(projectId); - if (!project) { - router.replace("/canvas"); - return; - } - - const restore = async () => { - const restoredNodes = await hydrateCanvasImages(resetInterruptedGeneration(project.nodes)); - const restoredSessions = await hydrateAssistantImages(project.chatSessions || []); - setNodes(restoredNodes); - setConnections(project.connections); - setChatSessions(restoredSessions); - setActiveChatId(project.activeChatId || null); - setBackgroundMode(project.backgroundMode); - setViewport(project.viewport); - historyRef.current = { past: [], future: [] }; - if (historyCommitTimerRef.current) { - clearTimeout(historyCommitTimerRef.current); - historyCommitTimerRef.current = null; - } - lastHistoryRef.current = { - nodes: restoredNodes, - connections: project.connections, - chatSessions: restoredSessions, - activeChatId: project.activeChatId || null, - backgroundMode: project.backgroundMode, - }; - setHistoryState({ canUndo: false, canRedo: false }); - setProjectLoaded(true); - }; - void restore(); - }, [hydrated, openProject, projectId, router]); - - useEffect(() => { - if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return; - const next = createHistoryEntry(); - const previous = lastHistoryRef.current; - if ( - previous?.nodes === next.nodes && - previous.connections === next.connections && - previous.chatSessions === next.chatSessions && - previous.activeChatId === next.activeChatId && - previous.backgroundMode === next.backgroundMode - ) return; - - if (historyCommitTimerRef.current) clearTimeout(historyCommitTimerRef.current); - historyCommitTimerRef.current = setTimeout(() => { - const current = createHistoryEntry(); - const last = lastHistoryRef.current; - if (!last) return; - historyRef.current.past = [...historyRef.current.past.slice(-49), last]; - historyRef.current.future = []; - setHistoryState({ canUndo: true, canRedo: false }); - lastHistoryRef.current = current; - historyCommitTimerRef.current = null; - }, 180); - - return () => { - if (historyCommitTimerRef.current) { - clearTimeout(historyCommitTimerRef.current); - historyCommitTimerRef.current = null; - } - }; - }, [activeChatId, backgroundMode, chatSessions, connections, createHistoryEntry, nodes, projectLoaded]); - - useEffect(() => { - if (!projectLoaded || historyPausedRef.current) return; - updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode }); - }, [activeChatId, backgroundMode, chatSessions, connections, nodes, projectId, projectLoaded, updateProject]); - - useEffect(() => { - if (!dialogNodeId) setNodeImageSettingsOpen(false); - }, [dialogNodeId]); - - useEffect(() => { - if (!projectLoaded) return; - if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); - viewportSaveTimerRef.current = setTimeout(() => { - updateProject(projectId, { viewport: viewportRef.current }); - viewportSaveTimerRef.current = null; - }, 500); - return () => { - if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); - }; - }, [projectId, projectLoaded, updateProject, viewport]); - - useLayoutEffect(() => { - nodesRef.current = nodes; - connectionsRef.current = connections; - selectedNodeIdsRef.current = selectedNodeIds; - viewportRef.current = viewport; - connectingParamsRef.current = connectingParams; - connectionTargetNodeIdRef.current = connectionTargetNodeId; - pendingConnectionCreateRef.current = pendingConnectionCreate; - }, [nodes, connections, selectedNodeIds, viewport, connectingParams, connectionTargetNodeId, pendingConnectionCreate]); - - useLayoutEffect(() => { - selectionBoxRef.current = selectionBox; - }, [selectionBox]); - - useEffect(() => { - const el = containerRef.current; - if (!el) return; - - const updateSize = () => { - const rect = el.getBoundingClientRect(); - setSize({ width: rect.width, height: rect.height }); - if (!didInitialCenterRef.current) { - didInitialCenterRef.current = true; - setViewport({ x: rect.width / 2, y: rect.height / 2, k: 1 }); - } - }; - - updateSize(); - const resizeObserver = new ResizeObserver(updateSize); - resizeObserver.observe(el); - return () => resizeObserver.disconnect(); - }, []); - - const screenToCanvas = useCallback((clientX: number, clientY: number) => { - const rect = containerRef.current?.getBoundingClientRect(); - const currentViewport = viewportRef.current; - const localX = clientX - (rect?.left || 0); - const localY = clientY - (rect?.top || 0); - - return { - x: (localX - currentViewport.x) / currentViewport.k, - y: (localY - currentViewport.y) / currentViewport.k, - }; - }, []); - - const getCanvasCenter = useCallback(() => { - const rect = containerRef.current?.getBoundingClientRect(); - return screenToCanvas((rect?.left || 0) + (rect?.width || size.width) / 2, (rect?.top || 0) + (rect?.height || size.height) / 2); - }, [screenToCanvas, size.height, size.width]); - - const setConnecting = useCallback((next: ConnectionHandle | null) => { - connectingParamsRef.current = next; - setConnectingParams(next); - if (!next) { - connectionTargetNodeIdRef.current = null; - setConnectionTargetNodeId(null); - } - }, []); - - const keepNodeToolbar = useCallback((nodeId: string) => { - if (nodeDraggingRef.current || nodeImageSettingsOpen) return; - if (toolbarHideTimerRef.current) { - clearTimeout(toolbarHideTimerRef.current); - toolbarHideTimerRef.current = null; - } - setToolbarNodeId(nodeId); - }, [nodeImageSettingsOpen]); - - const hideNodeToolbar = useCallback(() => { - if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current); - toolbarHideTimerRef.current = setTimeout(() => { - setToolbarNodeId(null); - toolbarHideTimerRef.current = null; - }, 120); - }, []); - - const connectNodes = useCallback((current: ConnectionHandle, targetNodeId: string) => { - if (current.nodeId === targetNodeId) return; - - const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType); - if (!connection) { - message.warning("配置节点之间不能连接"); - return; - } - const { fromNodeId, toNodeId } = connection; - const exists = connectionsRef.current.some((conn) => conn.fromNodeId === fromNodeId && conn.toNodeId === toNodeId); - if (!exists) { - setConnections((prev) => [...prev, { id: `conn-${Date.now()}`, fromNodeId, toNodeId }]); - } - setContextMenu(null); - }, [message]); - - const createConnectedNode = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config, pending: PendingConnectionCreate) => { - const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: 3 } : undefined; - const newNode = createCanvasNode(type, pending.position, metadata); - const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType); - if (!connection) { - message.warning("配置节点之间不能连接"); - return; - } - setNodes((prev) => [...prev, newNode]); - setConnections((prev) => [...prev, { id: nanoid(), ...connection }]); - setSelectedNodeIds(new Set([newNode.id])); - setSelectedConnectionId(null); - if (type !== CanvasNodeType.Text) setDialogNodeId(newNode.id); - setPendingConnectionCreate(null); - setConnecting(null); - }, [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting]); - - const cancelPendingConnectionCreate = useCallback(() => { - setPendingConnectionCreate(null); - setConnecting(null); - }, [setConnecting]); - - const getConnectableNodeAtPoint = useCallback((clientX: number, clientY: number, current: ConnectionHandle) => { - const world = screenToCanvas(clientX, clientY); - return [...nodesRef.current] - .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) - .reverse() - .find((node) => ( - node.id !== current.nodeId && - Boolean(normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) && - world.x >= node.position.x && - world.x <= node.position.x + node.width && - world.y >= node.position.y && - world.y <= node.position.y + node.height - ))?.id || null; - }, [screenToCanvas]); - - const visibleNodes = useMemo(() => { - const padding = 280; - const rect = containerRef.current?.getBoundingClientRect(); - const width = rect?.width || size.width; - const height = rect?.height || size.height; - const viewLeft = -viewport.x / viewport.k - padding; - const viewTop = -viewport.y / viewport.k - padding; - const viewRight = viewLeft + width / viewport.k + padding * 2; - const viewBottom = viewTop + height / viewport.k + padding * 2; - - return nodes.filter((node) => ( - !isHiddenBatchChild(node, nodes, collapsingBatchIds) && - node.position.x + node.width > viewLeft && - node.position.x < viewRight && - node.position.y + node.height > viewTop && - node.position.y < viewBottom - )); - }, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]); - - const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); - const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null; - const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null; - const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null; - const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null; - const hasMultipleSelectedNodes = selectedNodeIds.size > 1; - const activeNodeId = hasMultipleSelectedNodes ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null); - const batchChildCountById = useMemo(() => { - const map = new Map(); - nodes.forEach((node) => { - if (node.metadata?.isBatchRoot) map.set(node.id, node.metadata.batchChildIds?.length || 0); - }); - return map; - }, [nodes]); - const batchMotionById = useMemo(() => { - const map = new Map(); - nodes.forEach((node) => { - const rootId = node.metadata?.batchRootId; - if (!rootId) return; - const root = nodeById.get(rootId); - const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0; - const stackX = root ? root.position.x + 34 + index * 14 : node.position.x; - const stackY = root ? root.position.y + 14 + index * 8 : node.position.y; - map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) }); - }); - return map; - }, [nodeById, nodes]); - const relatedHighlight = useMemo(() => { - const nodeIds = new Set(); - const connectionIds = new Set(); - - if (!activeNodeId) return { nodeIds, connectionIds }; - - nodeIds.add(activeNodeId); - connections.forEach((connection) => { - if (connection.fromNodeId !== activeNodeId && connection.toNodeId !== activeNodeId) return; - connectionIds.add(connection.id); - nodeIds.add(connection.fromNodeId); - nodeIds.add(connection.toNodeId); + const { message } = App.useApp(); + const params = useParams<{ id: string }>(); + const router = useRouter(); + const projectId = params.id; + const containerRef = useRef(null); + const imageInputRef = useRef(null); + const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null); + const clipboardRef = useRef(null); + const historyRef = useRef<{ past: CanvasHistoryEntry[]; future: CanvasHistoryEntry[] }>({ past: [], future: [] }); + const lastHistoryRef = useRef(null); + const historyCommitTimerRef = useRef | null>(null); + const viewportSaveTimerRef = useRef | null>(null); + const applyingHistoryRef = useRef(false); + const historyPausedRef = useRef(false); + const didInitialCenterRef = useRef(false); + const rafRef = useRef(null); + const toolbarHideTimerRef = useRef | null>(null); + const nodeDraggingRef = useRef(false); + const dragRef = useRef<{ + isDraggingNode: boolean; + hasMoved: boolean; + startX: number; + startY: number; + initialSelectedNodes: { id: string; x: number; y: number }[]; + }>({ + isDraggingNode: false, + hasMoved: false, + startX: 0, + startY: 0, + initialSelectedNodes: [], }); - return { nodeIds, connectionIds }; - }, [activeNodeId, connections]); + const config = useConfigStore((state) => state.config); + const effectiveConfig = useEffectiveConfig(); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const openConfigDialog = useConfigStore((state) => state.openConfigDialog); + const addAsset = useAssetStore((state) => state.addAsset); + const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); + const hydrated = useCanvasStore((state) => state.hydrated); + const createProject = useCanvasStore((state) => state.createProject); + const openProject = useCanvasStore((state) => state.openProject); + const updateProject = useCanvasStore((state) => state.updateProject); + const renameProject = useCanvasStore((state) => state.renameProject); + const deleteProjects = useCanvasStore((state) => state.deleteProjects); + const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId)); + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const [nodes, setNodes] = useState([]); + const [connections, setConnections] = useState([]); + const [chatSessions, setChatSessions] = useState([]); + const [activeChatId, setActiveChatId] = useState(null); + const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); + const [size, setSize] = useState({ width: 1200, height: 720 }); + const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); + const [selectedConnectionId, setSelectedConnectionId] = useState(null); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [connectingParams, setConnectingParams] = useState(null); + const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null); + const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null); + const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 }); + const [selectionBox, setSelectionBox] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [runningNodeId, setRunningNodeId] = useState(null); + const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); + const [backgroundMode, setBackgroundMode] = useState("lines"); + const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + const [assetPickerOpen, setAssetPickerOpen] = useState(false); + const [assetPickerTab, setAssetPickerTab] = useState("my-assets"); + const [projectLoaded, setProjectLoaded] = useState(false); + const [toolbarNodeId, setToolbarNodeId] = useState(null); + const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); + const [dialogNodeId, setDialogNodeId] = useState(null); + const [editingNodeId, setEditingNodeId] = useState(null); + const [editRequestNonce, setEditRequestNonce] = useState(0); + const [infoNodeId, setInfoNodeId] = useState(null); + const [cropNodeId, setCropNodeId] = useState(null); + const [angleNodeId, setAngleNodeId] = useState(null); + const [assistantCollapsed, setAssistantCollapsed] = useState(true); + const [assistantMounted, setAssistantMounted] = useState(false); + const [titleEditing, setTitleEditing] = useState(false); + const [titleDraft, setTitleDraft] = useState(""); + const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }); + const [collapsingBatchIds, setCollapsingBatchIds] = useState>(new Set()); + const [openingBatchIds, setOpeningBatchIds] = useState>(new Set()); + const [isNodeDragging, setIsNodeDragging] = useState(false); - const configInputsById = useMemo(() => { - const map = new Map(); - nodes.forEach((node) => { - if (node.type !== CanvasNodeType.Config) return; - map.set(node.id, buildNodeGenerationInputs(node.id, nodes, connections)); - }); - return map; - }, [connections, nodes]); + const nodesRef = useRef(nodes); + const connectionsRef = useRef(connections); + const selectedNodeIdsRef = useRef(selectedNodeIds); + const viewportRef = useRef(viewport); + const connectingParamsRef = useRef(connectingParams); + const connectionTargetNodeIdRef = useRef(connectionTargetNodeId); + const selectionBoxRef = useRef(selectionBox); + const pendingConnectionCreateRef = useRef(pendingConnectionCreate); - const createNode = useCallback( - (type: CanvasNodeType, position?: Position) => { - const targetPosition = position || getCanvasCenter(); - const configMetadata = type === CanvasNodeType.Config ? { - model: effectiveConfig.imageModel || effectiveConfig.model, - size: effectiveConfig.size, - count: 3, - } : undefined; - const newNode = createCanvasNode(type, targetPosition, configMetadata); - - setNodes((prev) => [...prev, newNode]); - setSelectedNodeIds(new Set([newNode.id])); - setSelectedConnectionId(null); - if (type !== CanvasNodeType.Text) setDialogNodeId(newNode.id); - }, - [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter], - ); - - const deleteNodes = useCallback((ids: Set) => { - if (!ids.size) return; - const allIds = new Set(ids); - nodesRef.current.forEach((node) => { - if (ids.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => allIds.add(childId)); - }); - setNodes((prev) => { - const next = prev.filter((node) => !allIds.has(node.id)); - return next.map((node) => { - const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId)); - if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node; - const primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0]; - const primaryNode = next.find((item) => item.id === primaryImageId); - return { ...node, metadata: { ...node.metadata, batchChildIds: childIds, primaryImageId, content: primaryNode?.metadata?.content || node.metadata.content, naturalWidth: primaryNode?.metadata?.naturalWidth || node.metadata.naturalWidth, naturalHeight: primaryNode?.metadata?.naturalHeight || node.metadata.naturalHeight } }; - }); - }); - setConnections((prev) => prev.filter((conn) => !allIds.has(conn.fromNodeId) && !allIds.has(conn.toNodeId))); - setSelectedNodeIds(new Set()); - setSelectedConnectionId(null); - setHoveredNodeId((current) => current && allIds.has(current) ? null : current); - setToolbarNodeId((current) => current && allIds.has(current) ? null : current); - setDialogNodeId((current) => current && allIds.has(current) ? null : current); - setEditingNodeId((current) => current && allIds.has(current) ? null : current); - setInfoNodeId((current) => current && allIds.has(current) ? null : current); - setCropNodeId((current) => current && allIds.has(current) ? null : current); - setAngleNodeId((current) => current && allIds.has(current) ? null : current); - setRunningNodeId((current) => current && allIds.has(current) ? null : current); - setContextMenu((current) => current && allIds.has(current.nodeId) ? null : current); - cleanupAssetImages({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions }); - }, [chatSessions, cleanupAssetImages, projectId]); - - const deselectCanvas = useCallback(() => { - cancelPendingConnectionCreate(); - setSelectedNodeIds(new Set()); - setSelectedConnectionId(null); - setContextMenu(null); - setSelectionBox(null); - setHoveredNodeId(null); - setToolbarNodeId(null); - setDialogNodeId(null); - setEditingNodeId(null); - }, [cancelPendingConnectionCreate]); - - const clearCanvas = useCallback(() => { - setNodes([]); - setConnections([]); - setInfoNodeId(null); - setCropNodeId(null); - setAngleNodeId(null); - setRunningNodeId(null); - deselectCanvas(); - setClearConfirmOpen(false); - cleanupAssetImages({ projectId, nodes: [], chatSessions: [] }); - }, [cleanupAssetImages, deselectCanvas, projectId]); - - const duplicateNode = useCallback((nodeId: string) => { - const source = nodesRef.current.find((node) => node.id === nodeId); - if (!source) return; - - const id = `${source.type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const next: CanvasNodeData = { - ...source, - id, - title: `${source.title} Copy`, - position: { x: source.position.x + 36, y: source.position.y + 36 }, - }; - - setNodes((prev) => [...prev, next]); - setSelectedNodeIds(new Set([id])); - setSelectedConnectionId(null); - setDialogNodeId(id); - }, []); - - const copySelectedNodes = useCallback(() => { - const selectedIds = selectedNodeIdsRef.current; - if (!selectedIds.size) return; - - const copiedNodes = nodesRef.current - .filter((node) => selectedIds.has(node.id)) - .map((node) => ({ - ...node, - position: { ...node.position }, - metadata: node.metadata ? { ...node.metadata } : undefined, - })); - - if (!copiedNodes.length) return; - - clipboardRef.current = { - nodes: copiedNodes, - connections: connectionsRef.current - .filter((connection) => selectedIds.has(connection.fromNodeId) && selectedIds.has(connection.toNodeId)) - .map((connection) => ({ ...connection })), - }; - }, []); - - const pasteCopiedNodes = useCallback(() => { - const clipboard = clipboardRef.current; - if (!clipboard?.nodes.length) return false; - - const center = getCanvasCenter(); - const bounds = clipboard.nodes.reduce( - (acc, node) => ({ - left: Math.min(acc.left, node.position.x), - top: Math.min(acc.top, node.position.y), - right: Math.max(acc.right, node.position.x + node.width), - bottom: Math.max(acc.bottom, node.position.y + node.height), - }), - { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }, - ); - const dx = center.x - (bounds.left + bounds.right) / 2; - const dy = center.y - (bounds.top + bounds.bottom) / 2; - const idMap = new Map(); - const nextNodes = clipboard.nodes.map((node, index) => { - const id = `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`; - idMap.set(node.id, id); - return { - ...node, - id, - title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`, - position: { - x: node.position.x + dx, - y: node.position.y + dy, - }, - metadata: node.metadata ? { ...node.metadata } : undefined, - }; - }); - - const nextConnections = clipboard.connections.flatMap((connection, index) => { - const fromNodeId = idMap.get(connection.fromNodeId); - const toNodeId = idMap.get(connection.toNodeId); - if (!fromNodeId || !toNodeId) return []; - return [{ - ...connection, - id: `conn-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`, - fromNodeId, - toNodeId, - }]; - }); - - setNodes((prev) => [...prev, ...nextNodes]); - setConnections((prev) => [...prev, ...nextConnections]); - setSelectedNodeIds(new Set(nextNodes.map((node) => node.id))); - setSelectedConnectionId(null); - setContextMenu(null); - setDialogNodeId(nextNodes[0]?.id || null); - return true; - }, [getCanvasCenter]); - - const resetViewport = useCallback(() => { - setViewport({ x: size.width / 2, y: size.height / 2, k: 1 }); - setContextMenu(null); - }, [size.height, size.width]); - - const setZoomScale = useCallback((scale: number) => { - const nextScale = Math.min(Math.max(scale, 0.05), 5); - setViewport((prev) => ({ - x: size.width / 2 - ((size.width / 2 - prev.x) / prev.k) * nextScale, - y: size.height / 2 - ((size.height / 2 - prev.y) / prev.k) * nextScale, - k: nextScale, - })); - setContextMenu(null); - }, [size.height, size.width]); - - const applyHistory = useCallback((entry: CanvasHistoryEntry) => { - if (historyCommitTimerRef.current) { - clearTimeout(historyCommitTimerRef.current); - historyCommitTimerRef.current = null; - } - applyingHistoryRef.current = true; - setNodes(entry.nodes); - setConnections(entry.connections); - setChatSessions(entry.chatSessions); - setActiveChatId(entry.activeChatId); - setBackgroundMode(entry.backgroundMode); - setSelectedNodeIds(new Set()); - setSelectedConnectionId(null); - setContextMenu(null); - setTimeout(() => { - lastHistoryRef.current = entry; - applyingHistoryRef.current = false; - setHistoryState({ canUndo: historyRef.current.past.length > 0, canRedo: historyRef.current.future.length > 0 }); - }); - }, []); - - const undoCanvas = useCallback(() => { - const previous = historyRef.current.past.pop(); - const current = lastHistoryRef.current; - if (!previous || !current) return; - historyRef.current.future.push(current); - applyHistory(previous); - }, [applyHistory]); - - const redoCanvas = useCallback(() => { - const next = historyRef.current.future.pop(); - const current = lastHistoryRef.current; - if (!next || !current) return; - historyRef.current.past.push(current); - applyHistory(next); - }, [applyHistory]); - - const createAndOpenProject = useCallback(() => { - const id = createProject(`无限画布 ${useCanvasStore.getState().projects.length + 1}`); - router.push(`/canvas/${id}`); - }, [createProject, router]); - - const deleteCurrentProject = useCallback(() => { - deleteProjects([projectId]); - cleanupAssetImages(); - router.push("/canvas"); - }, [cleanupAssetImages, deleteProjects, projectId, router]); - - const handleCanvasMouseDown = useCallback( - (event: ReactPointerEvent) => { - setContextMenu(null); - if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); - if (event.button !== 0) return; - - if (!event.ctrlKey && !event.metaKey) { - setSelectionBox(null); - setSelectedNodeIds(new Set()); - setSelectedConnectionId(null); - return; - } - - const world = screenToCanvas(event.clientX, event.clientY); - const nextSelectionBox = { - startWorldX: world.x, - startWorldY: world.y, - currentWorldX: world.x, - currentWorldY: world.y, - additive: event.shiftKey, - initialSelectedNodeIds: event.shiftKey ? Array.from(selectedNodeIdsRef.current) : [], - }; - selectionBoxRef.current = nextSelectionBox; - setSelectionBox(nextSelectionBox); - if (!event.shiftKey) { - setSelectedNodeIds(new Set()); - } - - setSelectedConnectionId(null); - }, - [cancelPendingConnectionCreate, screenToCanvas], - ); - - const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => { - event.stopPropagation(); - setContextMenu(null); - setHoveredNodeId(null); - setToolbarNodeId(null); - setSelectedConnectionId(null); - - const currentSelected = selectedNodeIdsRef.current; - const currentNodes = nodesRef.current; - const nextSelected = new Set(currentSelected); - - if (event.shiftKey || event.metaKey || event.ctrlKey) { - if (nextSelected.has(nodeId)) { - nextSelected.delete(nodeId); - } else { - nextSelected.add(nodeId); - } - } else if (!nextSelected.has(nodeId)) { - nextSelected.clear(); - nextSelected.add(nodeId); - } - - setSelectedNodeIds(nextSelected); - const dragIds = new Set(nextSelected); - currentNodes.forEach((node) => { - if (nextSelected.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId)); - }); - dragRef.current = { - isDraggingNode: true, - hasMoved: false, - startX: event.clientX, - startY: event.clientY, - initialSelectedNodes: currentNodes - .filter((node) => dragIds.has(node.id)) - .map((node) => ({ id: node.id, x: node.position.x, y: node.position.y })), - }; - historyPausedRef.current = true; - nodeDraggingRef.current = true; - setIsNodeDragging(true); - }, []); - - const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => { - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - if (!dragRef.current.isDraggingNode) return; - - const wasClick = !dragRef.current.hasMoved && dragRef.current.initialSelectedNodes.length === 1; - const clickedNodeId = dragRef.current.initialSelectedNodes[0]?.id; - const currentViewport = viewportRef.current; - const dx = clientX == null ? 0 : (clientX - dragRef.current.startX) / currentViewport.k; - const dy = clientY == null ? 0 : (clientY - dragRef.current.startY) / currentViewport.k; - const initialPositions = dragRef.current.initialSelectedNodes; - - historyPausedRef.current = false; - nodeDraggingRef.current = false; - setIsNodeDragging(false); - if (dragRef.current.hasMoved && clientX != null && clientY != null) { - setNodes((prev) => - prev.map((node) => { - const initial = initialPositions.find((item) => item.id === node.id); - if (!initial) return node; - return { ...node, position: { x: initial.x + dx, y: initial.y + dy } }; + const createHistoryEntry = useCallback( + (): CanvasHistoryEntry => ({ + nodes: nodesRef.current, + connections: connectionsRef.current, + chatSessions, + activeChatId, + backgroundMode, }), - ); - } + [activeChatId, backgroundMode, chatSessions], + ); - dragRef.current.isDraggingNode = false; - dragRef.current.hasMoved = false; - dragRef.current.initialSelectedNodes = []; - if (wasClick && clickedNodeId) { - const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId); - if (clickedNode?.type === CanvasNodeType.Text) { - setDialogNodeId((current) => current === clickedNodeId ? current : null); - } else { - setDialogNodeId(clickedNodeId); - } - } - }, []); - - const handleGlobalMouseMove = useCallback( - (event: MouseEvent) => { - const currentViewport = viewportRef.current; - - if (dragRef.current.isDraggingNode) { - const dx = (event.clientX - dragRef.current.startX) / currentViewport.k; - const dy = (event.clientY - dragRef.current.startY) / currentViewport.k; - const initialPositions = dragRef.current.initialSelectedNodes; - if (Math.abs(event.clientX - dragRef.current.startX) > 3 || Math.abs(event.clientY - dragRef.current.startY) > 3) { - dragRef.current.hasMoved = true; + useEffect(() => { + if (!hydrated) return; + setProjectLoaded(false); + const project = openProject(projectId); + if (!project) { + router.replace("/canvas"); + return; } - if (rafRef.current) cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(() => { - setNodes((prev) => prev.map((node) => { - const initial = initialPositions.find((item) => item.id === node.id); - return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node; - })); - rafRef.current = null; + const restore = async () => { + const restoredNodes = await hydrateCanvasImages(resetInterruptedGeneration(project.nodes)); + const restoredSessions = await hydrateAssistantImages(project.chatSessions || []); + setNodes(restoredNodes); + setConnections(project.connections); + setChatSessions(restoredSessions); + setActiveChatId(project.activeChatId || null); + setBackgroundMode(project.backgroundMode); + setViewport(project.viewport); + historyRef.current = { past: [], future: [] }; + if (historyCommitTimerRef.current) { + clearTimeout(historyCommitTimerRef.current); + historyCommitTimerRef.current = null; + } + lastHistoryRef.current = { + nodes: restoredNodes, + connections: project.connections, + chatSessions: restoredSessions, + activeChatId: project.activeChatId || null, + backgroundMode: project.backgroundMode, + }; + setHistoryState({ canUndo: false, canRedo: false }); + setProjectLoaded(true); + }; + void restore(); + }, [hydrated, openProject, projectId, router]); + + useEffect(() => { + if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return; + const next = createHistoryEntry(); + const previous = lastHistoryRef.current; + if (previous?.nodes === next.nodes && previous.connections === next.connections && previous.chatSessions === next.chatSessions && previous.activeChatId === next.activeChatId && previous.backgroundMode === next.backgroundMode) return; + + if (historyCommitTimerRef.current) clearTimeout(historyCommitTimerRef.current); + historyCommitTimerRef.current = setTimeout(() => { + const current = createHistoryEntry(); + const last = lastHistoryRef.current; + if (!last) return; + historyRef.current.past = [...historyRef.current.past.slice(-49), last]; + historyRef.current.future = []; + setHistoryState({ canUndo: true, canRedo: false }); + lastHistoryRef.current = current; + historyCommitTimerRef.current = null; + }, 180); + + return () => { + if (historyCommitTimerRef.current) { + clearTimeout(historyCommitTimerRef.current); + historyCommitTimerRef.current = null; + } + }; + }, [activeChatId, backgroundMode, chatSessions, connections, createHistoryEntry, nodes, projectLoaded]); + + useEffect(() => { + if (!projectLoaded || historyPausedRef.current) return; + updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode }); + }, [activeChatId, backgroundMode, chatSessions, connections, nodes, projectId, projectLoaded, updateProject]); + + useEffect(() => { + if (!dialogNodeId) setNodeImageSettingsOpen(false); + }, [dialogNodeId]); + + useEffect(() => { + if (!projectLoaded) return; + if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); + viewportSaveTimerRef.current = setTimeout(() => { + updateProject(projectId, { viewport: viewportRef.current }); + viewportSaveTimerRef.current = null; + }, 500); + return () => { + if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); + }; + }, [projectId, projectLoaded, updateProject, viewport]); + + useLayoutEffect(() => { + nodesRef.current = nodes; + connectionsRef.current = connections; + selectedNodeIdsRef.current = selectedNodeIds; + viewportRef.current = viewport; + connectingParamsRef.current = connectingParams; + connectionTargetNodeIdRef.current = connectionTargetNodeId; + pendingConnectionCreateRef.current = pendingConnectionCreate; + }, [nodes, connections, selectedNodeIds, viewport, connectingParams, connectionTargetNodeId, pendingConnectionCreate]); + + useLayoutEffect(() => { + selectionBoxRef.current = selectionBox; + }, [selectionBox]); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const updateSize = () => { + const rect = el.getBoundingClientRect(); + setSize({ width: rect.width, height: rect.height }); + if (!didInitialCenterRef.current) { + didInitialCenterRef.current = true; + setViewport({ x: rect.width / 2, y: rect.height / 2, k: 1 }); + } + }; + + updateSize(); + const resizeObserver = new ResizeObserver(updateSize); + resizeObserver.observe(el); + return () => resizeObserver.disconnect(); + }, []); + + const screenToCanvas = useCallback((clientX: number, clientY: number) => { + const rect = containerRef.current?.getBoundingClientRect(); + const currentViewport = viewportRef.current; + const localX = clientX - (rect?.left || 0); + const localY = clientY - (rect?.top || 0); + + return { + x: (localX - currentViewport.x) / currentViewport.k, + y: (localY - currentViewport.y) / currentViewport.k, + }; + }, []); + + const getCanvasCenter = useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect(); + return screenToCanvas((rect?.left || 0) + (rect?.width || size.width) / 2, (rect?.top || 0) + (rect?.height || size.height) / 2); + }, [screenToCanvas, size.height, size.width]); + + const setConnecting = useCallback((next: ConnectionHandle | null) => { + connectingParamsRef.current = next; + setConnectingParams(next); + if (!next) { + connectionTargetNodeIdRef.current = null; + setConnectionTargetNodeId(null); + } + }, []); + + const keepNodeToolbar = useCallback( + (nodeId: string) => { + if (nodeDraggingRef.current || nodeImageSettingsOpen) return; + if (toolbarHideTimerRef.current) { + clearTimeout(toolbarHideTimerRef.current); + toolbarHideTimerRef.current = null; + } + setToolbarNodeId(nodeId); + }, + [nodeImageSettingsOpen], + ); + + const hideNodeToolbar = useCallback(() => { + if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current); + toolbarHideTimerRef.current = setTimeout(() => { + setToolbarNodeId(null); + toolbarHideTimerRef.current = null; + }, 120); + }, []); + + const connectNodes = useCallback( + (current: ConnectionHandle, targetNodeId: string) => { + if (current.nodeId === targetNodeId) return; + + const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType); + if (!connection) { + message.warning("配置节点之间不能连接"); + return; + } + const { fromNodeId, toNodeId } = connection; + const exists = connectionsRef.current.some((conn) => conn.fromNodeId === fromNodeId && conn.toNodeId === toNodeId); + if (!exists) { + setConnections((prev) => [...prev, { id: `conn-${Date.now()}`, fromNodeId, toNodeId }]); + } + setContextMenu(null); + }, + [message], + ); + + const createConnectedNode = useCallback( + (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config, pending: PendingConnectionCreate) => { + const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: 3 } : undefined; + const newNode = createCanvasNode(type, pending.position, metadata); + const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType); + if (!connection) { + message.warning("配置节点之间不能连接"); + return; + } + setNodes((prev) => [...prev, newNode]); + setConnections((prev) => [...prev, { id: nanoid(), ...connection }]); + setSelectedNodeIds(new Set([newNode.id])); + setSelectedConnectionId(null); + if (type !== CanvasNodeType.Text) setDialogNodeId(newNode.id); + setPendingConnectionCreate(null); + setConnecting(null); + }, + [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting], + ); + + const cancelPendingConnectionCreate = useCallback(() => { + setPendingConnectionCreate(null); + setConnecting(null); + }, [setConnecting]); + + const getConnectableNodeAtPoint = useCallback( + (clientX: number, clientY: number, current: ConnectionHandle) => { + const world = screenToCanvas(clientX, clientY); + return ( + [...nodesRef.current] + .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) + .reverse() + .find( + (node) => + node.id !== current.nodeId && + Boolean(normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) && + world.x >= node.position.x && + world.x <= node.position.x + node.width && + world.y >= node.position.y && + world.y <= node.position.y + node.height, + )?.id || null + ); + }, + [screenToCanvas], + ); + + const visibleNodes = useMemo(() => { + const padding = 280; + const rect = containerRef.current?.getBoundingClientRect(); + const width = rect?.width || size.width; + const height = rect?.height || size.height; + const viewLeft = -viewport.x / viewport.k - padding; + const viewTop = -viewport.y / viewport.k - padding; + const viewRight = viewLeft + width / viewport.k + padding * 2; + const viewBottom = viewTop + height / viewport.k + padding * 2; + + return nodes.filter((node) => !isHiddenBatchChild(node, nodes, collapsingBatchIds) && node.position.x + node.width > viewLeft && node.position.x < viewRight && node.position.y + node.height > viewTop && node.position.y < viewBottom); + }, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]); + + const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); + const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null; + const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null; + const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null; + const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null; + const hasMultipleSelectedNodes = selectedNodeIds.size > 1; + const activeNodeId = hasMultipleSelectedNodes ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null); + const batchChildCountById = useMemo(() => { + const map = new Map(); + nodes.forEach((node) => { + if (node.metadata?.isBatchRoot) map.set(node.id, node.metadata.batchChildIds?.length || 0); }); - return; - } + return map; + }, [nodes]); + const batchMotionById = useMemo(() => { + const map = new Map(); + nodes.forEach((node) => { + const rootId = node.metadata?.batchRootId; + if (!rootId) return; + const root = nodeById.get(rootId); + const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0; + const stackX = root ? root.position.x + 34 + index * 14 : node.position.x; + const stackY = root ? root.position.y + 14 + index * 8 : node.position.y; + map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) }); + }); + return map; + }, [nodeById, nodes]); + const relatedHighlight = useMemo(() => { + const nodeIds = new Set(); + const connectionIds = new Set(); - if (connectingParamsRef.current && !pendingConnectionCreateRef.current) { - const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, connectingParamsRef.current); - connectionTargetNodeIdRef.current = targetNodeId; - setConnectionTargetNodeId(targetNodeId); - setMouseWorld(screenToCanvas(event.clientX, event.clientY)); - } - }, - [finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas], - ); + if (!activeNodeId) return { nodeIds, connectionIds }; - const handleGlobalPointerMove = useCallback( - (event: PointerEvent) => { - const currentSelection = selectionBoxRef.current; - if (!currentSelection) return; + nodeIds.add(activeNodeId); + connections.forEach((connection) => { + if (connection.fromNodeId !== activeNodeId && connection.toNodeId !== activeNodeId) return; + connectionIds.add(connection.id); + nodeIds.add(connection.fromNodeId); + nodeIds.add(connection.toNodeId); + }); - if (event.buttons === 0) { - selectionBoxRef.current = null; - setSelectionBox(null); - return; - } + return { nodeIds, connectionIds }; + }, [activeNodeId, connections]); - const world = screenToCanvas(event.clientX, event.clientY); - const rectX = Math.min(currentSelection.startWorldX, world.x); - const rectY = Math.min(currentSelection.startWorldY, world.y); - const rectW = Math.abs(world.x - currentSelection.startWorldX); - const rectH = Math.abs(world.y - currentSelection.startWorldY); - const nextSelected = new Set(currentSelection.additive ? currentSelection.initialSelectedNodeIds : []); + const configInputsById = useMemo(() => { + const map = new Map(); + nodes.forEach((node) => { + if (node.type !== CanvasNodeType.Config) return; + map.set(node.id, buildNodeGenerationInputs(node.id, nodes, connections)); + }); + return map; + }, [connections, nodes]); - nodesRef.current.filter((node) => !isHiddenBatchChild(node, nodesRef.current)).forEach((node) => { - const intersects = - rectX < node.position.x + node.width && - rectX + rectW > node.position.x && - rectY < node.position.y + node.height && - rectY + rectH > node.position.y; + const createNode = useCallback( + (type: CanvasNodeType, position?: Position) => { + const targetPosition = position || getCanvasCenter(); + const configMetadata = + type === CanvasNodeType.Config + ? { + model: effectiveConfig.imageModel || effectiveConfig.model, + size: effectiveConfig.size, + count: 3, + } + : undefined; + const newNode = createCanvasNode(type, targetPosition, configMetadata); - if (intersects) nextSelected.add(node.id); - }); + setNodes((prev) => [...prev, newNode]); + setSelectedNodeIds(new Set([newNode.id])); + setSelectedConnectionId(null); + if (type !== CanvasNodeType.Text) setDialogNodeId(newNode.id); + }, + [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter], + ); - const nextSelectionBox = { ...currentSelection, currentWorldX: world.x, currentWorldY: world.y }; - selectionBoxRef.current = nextSelectionBox; - setSelectionBox(nextSelectionBox); - setSelectedNodeIds(nextSelected); - }, - [screenToCanvas], - ); + const deleteNodes = useCallback( + (ids: Set) => { + if (!ids.size) return; + const allIds = new Set(ids); + nodesRef.current.forEach((node) => { + if (ids.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => allIds.add(childId)); + }); + setNodes((prev) => { + const next = prev.filter((node) => !allIds.has(node.id)); + return next.map((node) => { + const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId)); + if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node; + const primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0]; + const primaryNode = next.find((item) => item.id === primaryImageId); + return { + ...node, + metadata: { + ...node.metadata, + batchChildIds: childIds, + primaryImageId, + content: primaryNode?.metadata?.content || node.metadata.content, + naturalWidth: primaryNode?.metadata?.naturalWidth || node.metadata.naturalWidth, + naturalHeight: primaryNode?.metadata?.naturalHeight || node.metadata.naturalHeight, + }, + }; + }); + }); + setConnections((prev) => prev.filter((conn) => !allIds.has(conn.fromNodeId) && !allIds.has(conn.toNodeId))); + setSelectedNodeIds(new Set()); + setSelectedConnectionId(null); + setHoveredNodeId((current) => (current && allIds.has(current) ? null : current)); + setToolbarNodeId((current) => (current && allIds.has(current) ? null : current)); + setDialogNodeId((current) => (current && allIds.has(current) ? null : current)); + setEditingNodeId((current) => (current && allIds.has(current) ? null : current)); + setInfoNodeId((current) => (current && allIds.has(current) ? null : current)); + setCropNodeId((current) => (current && allIds.has(current) ? null : current)); + setAngleNodeId((current) => (current && allIds.has(current) ? null : current)); + setRunningNodeId((current) => (current && allIds.has(current) ? null : current)); + setContextMenu((current) => (current && allIds.has(current.nodeId) ? null : current)); + cleanupAssetImages({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions }); + }, + [chatSessions, cleanupAssetImages, projectId], + ); - const handleGlobalMouseUp = useCallback( - (event: MouseEvent) => { - finishNodeDrag(event.clientX, event.clientY); - - selectionBoxRef.current = null; - setSelectionBox(null); - - if (pendingConnectionCreateRef.current) return; - - const currentConnection = connectingParamsRef.current; - if (currentConnection) { - const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, currentConnection) || connectionTargetNodeIdRef.current; - if (targetNodeId) { - connectNodes(currentConnection, targetNodeId); - setConnecting(null); - } else { - setMouseWorld(screenToCanvas(event.clientX, event.clientY)); - setPendingConnectionCreate({ connection: currentConnection, position: screenToCanvas(event.clientX, event.clientY) }); - } - } - }, - [connectNodes, finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas, setConnecting], - ); - - useEffect(() => { - const handlePointerUp = (event: PointerEvent) => finishNodeDrag(event.clientX, event.clientY); - const cancelNodeDrag = () => finishNodeDrag(); - window.addEventListener("mousemove", handleGlobalMouseMove); - window.addEventListener("mouseup", handleGlobalMouseUp); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", cancelNodeDrag); - window.addEventListener("blur", cancelNodeDrag); - window.addEventListener("pointermove", handleGlobalPointerMove); - return () => { - window.removeEventListener("mousemove", handleGlobalMouseMove); - window.removeEventListener("mouseup", handleGlobalMouseUp); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", cancelNodeDrag); - window.removeEventListener("blur", cancelNodeDrag); - window.removeEventListener("pointermove", handleGlobalPointerMove); - }; - }, [finishNodeDrag, handleGlobalMouseMove, handleGlobalMouseUp, handleGlobalPointerMove]); - - const createImageFileNode = useCallback(async (file: File, position: Position) => { - const image = await uploadImage(file); - const size = fitImageNodeSize(image.width, image.height); - const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const newNode: CanvasNodeData = { - id, - type: CanvasNodeType.Image, - title: file.name, - position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, - width: size.width, - height: size.height, - metadata: imageMetadata(image), - }; - - setNodes((prev) => [...prev, newNode]); - setSelectedNodeIds(new Set([id])); - setSelectedConnectionId(null); - setDialogNodeId(id); - }, []); - - const createTextNodeFromClipboard = useCallback((text: string) => { - const trimmed = text.trim(); - if (!trimmed) return false; - - const node = { - ...createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }), - title: trimmed.slice(0, 32) || "剪切板文本", - }; - - setNodes((prev) => [...prev, node]); - setSelectedNodeIds(new Set([node.id])); - setSelectedConnectionId(null); - setContextMenu(null); - setDialogNodeId(node.id); - return true; - }, [getCanvasCenter]); - - const pasteSystemClipboard = useCallback(async () => { - if (!navigator.clipboard) return; - - const items = await navigator.clipboard.read(); - const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/"))); - if (imageItem) { - const imageType = imageItem.types.find((type) => type.startsWith("image/")); - if (!imageType) return; - const blob = await imageItem.getType(imageType); - const file = new File([blob], "clipboard-image.png", { type: imageType }); - void createImageFileNode(file, getCanvasCenter()); - message.success("已从剪切板添加图片"); - return; - } - - const text = await navigator.clipboard.readText(); - if (createTextNodeFromClipboard(text)) message.success("已从剪切板添加文本"); - }, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message]); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return; - - const key = event.key.toLowerCase(); - const isModifierShortcut = event.metaKey || event.ctrlKey; - - if (isModifierShortcut && !event.altKey && key === "z") { - event.preventDefault(); - if (event.shiftKey) redoCanvas(); - else undoCanvas(); - return; - } - - if (isModifierShortcut && !event.altKey && key === "y") { - event.preventDefault(); - redoCanvas(); - return; - } - - if (isModifierShortcut && !event.altKey && key === "a") { - event.preventDefault(); - setSelectedNodeIds(new Set(nodesRef.current.map((node) => node.id))); - setSelectedConnectionId(null); - setContextMenu(null); - setSelectionBox(null); - return; - } - - if (isModifierShortcut && !event.altKey && key === "c") { - event.preventDefault(); - copySelectedNodes(); - return; - } - - if (isModifierShortcut && !event.altKey && key === "v") { - event.preventDefault(); - if (!pasteCopiedNodes()) void pasteSystemClipboard(); - return; - } - - if (event.key === "Delete" || event.key === "Backspace") { - if (selectedNodeIdsRef.current.size) { - deleteNodes(new Set(selectedNodeIdsRef.current)); - } else if (selectedConnectionId) { - setConnections((prev) => prev.filter((conn) => conn.id !== selectedConnectionId)); - setSelectedConnectionId(null); - } - } - - if (event.key === "Escape") { + const deselectCanvas = useCallback(() => { + cancelPendingConnectionCreate(); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setContextMenu(null); setSelectionBox(null); - setConnecting(null); setHoveredNodeId(null); setToolbarNodeId(null); setDialogNodeId(null); setEditingNodeId(null); + }, [cancelPendingConnectionCreate]); + + const clearCanvas = useCallback(() => { + setNodes([]); + setConnections([]); setInfoNodeId(null); setCropNodeId(null); - setPendingConnectionCreate(null); - } - }; + setAngleNodeId(null); + setRunningNodeId(null); + deselectCanvas(); + setClearConfirmOpen(false); + cleanupAssetImages({ projectId, nodes: [], chatSessions: [] }); + }, [cleanupAssetImages, deselectCanvas, projectId]); - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [copySelectedNodes, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]); + const duplicateNode = useCallback((nodeId: string) => { + const source = nodesRef.current.find((node) => node.id === nodeId); + if (!source) return; - const handleConnectStart = useCallback( - (event: ReactMouseEvent, nodeId: string, handleType: "source" | "target") => { - event.stopPropagation(); - setMouseWorld(screenToCanvas(event.clientX, event.clientY)); - setConnecting({ nodeId, handleType }); - connectionTargetNodeIdRef.current = null; - setConnectionTargetNodeId(null); - setSelectedConnectionId(null); - }, - [screenToCanvas, setConnecting], - ); + const id = `${source.type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const next: CanvasNodeData = { + ...source, + id, + title: `${source.title} Copy`, + position: { x: source.position.x + 36, y: source.position.y + 36 }, + }; - const handleNodeResize = useCallback((nodeId: string, width: number, height: number, position?: Position) => { - setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, width, height, position: position || node.position } : node))); - }, []); + setNodes((prev) => [...prev, next]); + setSelectedNodeIds(new Set([id])); + setSelectedConnectionId(null); + setDialogNodeId(id); + }, []); - const toggleNodeFreeResize = useCallback((nodeId: string) => { - setNodes((prev) => prev.map((node) => { - if (node.id !== nodeId) return node; - const freeResize = !node.metadata?.freeResize; - if (freeResize || node.type !== CanvasNodeType.Image) return { ...node, metadata: { ...node.metadata, freeResize } }; - const ratio = (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1); - const height = node.width / ratio; - return { ...node, height, position: { x: node.position.x, y: node.position.y + node.height / 2 - height / 2 }, metadata: { ...node.metadata, freeResize } }; - })); - }, []); + const copySelectedNodes = useCallback(() => { + const selectedIds = selectedNodeIdsRef.current; + if (!selectedIds.size) return; - const handleNodeContentChange = useCallback((nodeId: string, content: string) => { - setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node))); - }, []); + const copiedNodes = nodesRef.current + .filter((node) => selectedIds.has(node.id)) + .map((node) => ({ + ...node, + position: { ...node.position }, + metadata: node.metadata ? { ...node.metadata } : undefined, + })); - const toggleBatchExpanded = useCallback((nodeId: string) => { - const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded); - if (isExpanded) { - setCollapsingBatchIds((prev) => new Set(prev).add(nodeId)); - window.setTimeout(() => { - setCollapsingBatchIds((prev) => { - const next = new Set(prev); - next.delete(nodeId); - return next; + if (!copiedNodes.length) return; + + clipboardRef.current = { + nodes: copiedNodes, + connections: connectionsRef.current.filter((connection) => selectedIds.has(connection.fromNodeId) && selectedIds.has(connection.toNodeId)).map((connection) => ({ ...connection })), + }; + }, []); + + const pasteCopiedNodes = useCallback(() => { + const clipboard = clipboardRef.current; + if (!clipboard?.nodes.length) return false; + + const center = getCanvasCenter(); + const bounds = clipboard.nodes.reduce( + (acc, node) => ({ + left: Math.min(acc.left, node.position.x), + top: Math.min(acc.top, node.position.y), + right: Math.max(acc.right, node.position.x + node.width), + bottom: Math.max(acc.bottom, node.position.y + node.height), + }), + { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }, + ); + const dx = center.x - (bounds.left + bounds.right) / 2; + const dy = center.y - (bounds.top + bounds.bottom) / 2; + const idMap = new Map(); + const nextNodes = clipboard.nodes.map((node, index) => { + const id = `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`; + idMap.set(node.id, id); + return { + ...node, + id, + title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`, + position: { + x: node.position.x + dx, + y: node.position.y + dy, + }, + metadata: node.metadata ? { ...node.metadata } : undefined, + }; }); - }, 320); - } else { - setOpeningBatchIds((prev) => new Set(prev).add(nodeId)); - window.setTimeout(() => { - setOpeningBatchIds((prev) => { - const next = new Set(prev); - next.delete(nodeId); - return next; + + const nextConnections = clipboard.connections.flatMap((connection, index) => { + const fromNodeId = idMap.get(connection.fromNodeId); + const toNodeId = idMap.get(connection.toNodeId); + if (!fromNodeId || !toNodeId) return []; + return [ + { + ...connection, + id: `conn-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`, + fromNodeId, + toNodeId, + }, + ]; }); - }, 260); - } - setNodes((prev) => prev.map((node) => { - if (node.id !== nodeId) return node; - return { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } }; - })); - }, []); - const setBatchPrimary = useCallback((child: CanvasNodeData) => { - const rootId = child.metadata?.batchRootId; - if (!rootId || !child.metadata?.content) return; - setNodes((prev) => prev.map((node) => node.id === rootId ? { - ...node, - width: child.width, - height: child.height, - metadata: { - ...node.metadata, - content: child.metadata?.content, - primaryImageId: child.id, - naturalWidth: child.metadata?.naturalWidth, - naturalHeight: child.metadata?.naturalHeight, - freeResize: child.metadata?.freeResize, - }, - } : node)); - }, []); + setNodes((prev) => [...prev, ...nextNodes]); + setConnections((prev) => [...prev, ...nextConnections]); + setSelectedNodeIds(new Set(nextNodes.map((node) => node.id))); + setSelectedConnectionId(null); + setContextMenu(null); + setDialogNodeId(nextNodes[0]?.id || null); + return true; + }, [getCanvasCenter]); - const openTextEditor = useCallback((node: CanvasNodeData) => { - if (node.type !== CanvasNodeType.Text) return; - setSelectedNodeIds(new Set([node.id])); - setSelectedConnectionId(null); - setDialogNodeId(node.id); - setEditingNodeId(node.id); - setEditRequestNonce((value) => value + 1); - }, []); + const resetViewport = useCallback(() => { + setViewport({ x: size.width / 2, y: size.height / 2, k: 1 }); + setContextMenu(null); + }, [size.height, size.width]); - const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => { - setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node))); - }, []); + const setZoomScale = useCallback( + (scale: number) => { + const nextScale = Math.min(Math.max(scale, 0.05), 5); + setViewport((prev) => ({ + x: size.width / 2 - ((size.width / 2 - prev.x) / prev.k) * nextScale, + y: size.height / 2 - ((size.height / 2 - prev.y) / prev.k) * nextScale, + k: nextScale, + })); + setContextMenu(null); + }, + [size.height, size.width], + ); - const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial) => { - setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(patch || {}) } } : node))); - }, []); + const applyHistory = useCallback((entry: CanvasHistoryEntry) => { + if (historyCommitTimerRef.current) { + clearTimeout(historyCommitTimerRef.current); + historyCommitTimerRef.current = null; + } + applyingHistoryRef.current = true; + setNodes(entry.nodes); + setConnections(entry.connections); + setChatSessions(entry.chatSessions); + setActiveChatId(entry.activeChatId); + setBackgroundMode(entry.backgroundMode); + setSelectedNodeIds(new Set()); + setSelectedConnectionId(null); + setContextMenu(null); + setTimeout(() => { + lastHistoryRef.current = entry; + applyingHistoryRef.current = false; + setHistoryState({ canUndo: historyRef.current.past.length > 0, canRedo: historyRef.current.future.length > 0 }); + }); + }, []); - const downloadNodeImage = useCallback((node: CanvasNodeData) => { - if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return; - const link = document.createElement("a"); - link.href = node.metadata.content; - link.download = `canvas-image-${node.id}.${imageExtension(node.metadata.content)}`; - link.click(); - }, []); + const undoCanvas = useCallback(() => { + const previous = historyRef.current.past.pop(); + const current = lastHistoryRef.current; + if (!previous || !current) return; + historyRef.current.future.push(current); + applyHistory(previous); + }, [applyHistory]); - const saveNodeAsset = useCallback(async (node: CanvasNodeData) => { - if (node.type === CanvasNodeType.Text) { - const content = node.metadata?.content?.trim(); - if (!content) return message.error("没有可保存的文本"); - addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } }); - message.success("已加入我的素材"); - return; - } - if (!node.metadata?.content) return message.error("没有可保存的图片"); - const dataUrl = node.metadata.storageKey ? "" : node.metadata.content; - addAsset({ kind: "image", title: node.metadata?.prompt?.slice(0, 24) || "画布图片", coverUrl: node.metadata.content, tags: [], source: "Canvas", data: { dataUrl, storageKey: node.metadata.storageKey, width: node.metadata.naturalWidth || node.width, height: node.metadata.naturalHeight || node.height, bytes: node.metadata.bytes || getDataUrlByteSize(dataUrl), mimeType: node.metadata.mimeType || "image/png" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } }); - message.success("已加入我的素材"); - }, [addAsset, message]); + const redoCanvas = useCallback(() => { + const next = historyRef.current.future.pop(); + const current = lastHistoryRef.current; + if (!next || !current) return; + historyRef.current.past.push(current); + applyHistory(next); + }, [applyHistory]); - const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => { - if (!node.metadata?.content) return; - const cropped = await cropDataUrl(node.metadata.content, crop); - const image = await uploadImage(cropped); - const width = Math.min(node.width, Math.max(220, image.width)); - const childId = nanoid(); - const child: CanvasNodeData = { - id: childId, - type: CanvasNodeType.Image, - title: "Cropped Image", - position: { x: node.position.x + node.width + 96, y: node.position.y }, - width, - height: width * (image.height / image.width), - metadata: { - ...imageMetadata(image), - prompt: node.metadata?.prompt, - }, - }; - setNodes((prev) => [...prev, child]); - setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); - setSelectedNodeIds(new Set([childId])); - setDialogNodeId(childId); - setCropNodeId(null); - }, []); + const createAndOpenProject = useCallback(() => { + const id = createProject(`无限画布 ${useCanvasStore.getState().projects.length + 1}`); + router.push(`/canvas/${id}`); + }, [createProject, router]); - const generateAngleNode = useCallback(async (node: CanvasNodeData, params: CanvasImageAngleParams) => { - if (!node.metadata?.content) return; - const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" }; - if (!isAiConfigReady(generationConfig, generationConfig.model)) { - openConfigDialog(true); - return; - } - const childId = nanoid(); - const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; - const title = buildAngleLabel(params); - const prompt = buildAnglePrompt(params); - const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }]); - setAngleNodeId(null); - setRunningNodeId(childId); - setNodes((prev) => [...prev, { - id: childId, - type: CanvasNodeType.Image, - title, - position: { x: node.position.x + node.width + 96, y: node.position.y }, - width: imageConfig.width, - height: imageConfig.height, - metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata }, - }]); - setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); - setSelectedNodeIds(new Set([childId])); - setDialogNodeId(childId); - try { - const image = await requestEdit(generationConfig, prompt, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }]).then((items) => items[0]); - const uploaded = await uploadImage(image.dataUrl); - const size = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); - setNodes((prev) => prev.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)); - } catch (error) { - const errorDetails = error instanceof Error ? error.message : "生成失败"; - setNodes((prev) => prev.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)); - } finally { - setRunningNodeId(null); - } - }, [effectiveConfig, openConfigDialog]); + const deleteCurrentProject = useCallback(() => { + deleteProjects([projectId]); + cleanupAssetImages(); + router.push("/canvas"); + }, [cleanupAssetImages, deleteProjects, projectId, router]); - const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => { - setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, fontSize } } : node))); - }, []); + const handleCanvasMouseDown = useCallback( + (event: ReactPointerEvent) => { + setContextMenu(null); + if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); + if (event.button !== 0) return; - const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => { - uploadTargetRef.current = { nodeId, position }; - imageInputRef.current?.click(); - }, []); + if (!event.ctrlKey && !event.metaKey) { + setSelectionBox(null); + setSelectedNodeIds(new Set()); + setSelectedConnectionId(null); + return; + } - const handleImageInputChange = useCallback( - async (event: ReactChangeEvent) => { - const file = event.target.files?.[0]; - const target = uploadTargetRef.current; - if (!file || !file.type.startsWith("image/")) return; + const world = screenToCanvas(event.clientX, event.clientY); + const nextSelectionBox = { + startWorldX: world.x, + startWorldY: world.y, + currentWorldX: world.x, + currentWorldY: world.y, + additive: event.shiftKey, + initialSelectedNodeIds: event.shiftKey ? Array.from(selectedNodeIdsRef.current) : [], + }; + selectionBoxRef.current = nextSelectionBox; + setSelectionBox(nextSelectionBox); + if (!event.shiftKey) { + setSelectedNodeIds(new Set()); + } - if (target?.nodeId) { + setSelectedConnectionId(null); + }, + [cancelPendingConnectionCreate, screenToCanvas], + ); + + const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => { + event.stopPropagation(); + setContextMenu(null); + setHoveredNodeId(null); + setToolbarNodeId(null); + setSelectedConnectionId(null); + + const currentSelected = selectedNodeIdsRef.current; + const currentNodes = nodesRef.current; + const nextSelected = new Set(currentSelected); + + if (event.shiftKey || event.metaKey || event.ctrlKey) { + if (nextSelected.has(nodeId)) { + nextSelected.delete(nodeId); + } else { + nextSelected.add(nodeId); + } + } else if (!nextSelected.has(nodeId)) { + nextSelected.clear(); + nextSelected.add(nodeId); + } + + setSelectedNodeIds(nextSelected); + const dragIds = new Set(nextSelected); + currentNodes.forEach((node) => { + if (nextSelected.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId)); + }); + dragRef.current = { + isDraggingNode: true, + hasMoved: false, + startX: event.clientX, + startY: event.clientY, + initialSelectedNodes: currentNodes.filter((node) => dragIds.has(node.id)).map((node) => ({ id: node.id, x: node.position.x, y: node.position.y })), + }; + historyPausedRef.current = true; + nodeDraggingRef.current = true; + setIsNodeDragging(true); + }, []); + + const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + if (!dragRef.current.isDraggingNode) return; + + const wasClick = !dragRef.current.hasMoved && dragRef.current.initialSelectedNodes.length === 1; + const clickedNodeId = dragRef.current.initialSelectedNodes[0]?.id; + const currentViewport = viewportRef.current; + const dx = clientX == null ? 0 : (clientX - dragRef.current.startX) / currentViewport.k; + const dy = clientY == null ? 0 : (clientY - dragRef.current.startY) / currentViewport.k; + const initialPositions = dragRef.current.initialSelectedNodes; + + historyPausedRef.current = false; + nodeDraggingRef.current = false; + setIsNodeDragging(false); + if (dragRef.current.hasMoved && clientX != null && clientY != null) { + setNodes((prev) => + prev.map((node) => { + const initial = initialPositions.find((item) => item.id === node.id); + if (!initial) return node; + return { ...node, position: { x: initial.x + dx, y: initial.y + dy } }; + }), + ); + } + + dragRef.current.isDraggingNode = false; + dragRef.current.hasMoved = false; + dragRef.current.initialSelectedNodes = []; + if (wasClick && clickedNodeId) { + const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId); + if (clickedNode?.type === CanvasNodeType.Text) { + setDialogNodeId((current) => (current === clickedNodeId ? current : null)); + } else { + setDialogNodeId(clickedNodeId); + } + } + }, []); + + const handleGlobalMouseMove = useCallback( + (event: MouseEvent) => { + const currentViewport = viewportRef.current; + + if (dragRef.current.isDraggingNode) { + const dx = (event.clientX - dragRef.current.startX) / currentViewport.k; + const dy = (event.clientY - dragRef.current.startY) / currentViewport.k; + const initialPositions = dragRef.current.initialSelectedNodes; + if (Math.abs(event.clientX - dragRef.current.startX) > 3 || Math.abs(event.clientY - dragRef.current.startY) > 3) { + dragRef.current.hasMoved = true; + } + + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + setNodes((prev) => + prev.map((node) => { + const initial = initialPositions.find((item) => item.id === node.id); + return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node; + }), + ); + rafRef.current = null; + }); + return; + } + + if (connectingParamsRef.current && !pendingConnectionCreateRef.current) { + const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, connectingParamsRef.current); + connectionTargetNodeIdRef.current = targetNodeId; + setConnectionTargetNodeId(targetNodeId); + setMouseWorld(screenToCanvas(event.clientX, event.clientY)); + } + }, + [finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas], + ); + + const handleGlobalPointerMove = useCallback( + (event: PointerEvent) => { + const currentSelection = selectionBoxRef.current; + if (!currentSelection) return; + + if (event.buttons === 0) { + selectionBoxRef.current = null; + setSelectionBox(null); + return; + } + + const world = screenToCanvas(event.clientX, event.clientY); + const rectX = Math.min(currentSelection.startWorldX, world.x); + const rectY = Math.min(currentSelection.startWorldY, world.y); + const rectW = Math.abs(world.x - currentSelection.startWorldX); + const rectH = Math.abs(world.y - currentSelection.startWorldY); + const nextSelected = new Set(currentSelection.additive ? currentSelection.initialSelectedNodeIds : []); + + nodesRef.current + .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) + .forEach((node) => { + const intersects = rectX < node.position.x + node.width && rectX + rectW > node.position.x && rectY < node.position.y + node.height && rectY + rectH > node.position.y; + + if (intersects) nextSelected.add(node.id); + }); + + const nextSelectionBox = { ...currentSelection, currentWorldX: world.x, currentWorldY: world.y }; + selectionBoxRef.current = nextSelectionBox; + setSelectionBox(nextSelectionBox); + setSelectedNodeIds(nextSelected); + }, + [screenToCanvas], + ); + + const handleGlobalMouseUp = useCallback( + (event: MouseEvent) => { + finishNodeDrag(event.clientX, event.clientY); + + selectionBoxRef.current = null; + setSelectionBox(null); + + if (pendingConnectionCreateRef.current) return; + + const currentConnection = connectingParamsRef.current; + if (currentConnection) { + const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, currentConnection) || connectionTargetNodeIdRef.current; + if (targetNodeId) { + connectNodes(currentConnection, targetNodeId); + setConnecting(null); + } else { + setMouseWorld(screenToCanvas(event.clientX, event.clientY)); + setPendingConnectionCreate({ connection: currentConnection, position: screenToCanvas(event.clientX, event.clientY) }); + } + } + }, + [connectNodes, finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas, setConnecting], + ); + + useEffect(() => { + const handlePointerUp = (event: PointerEvent) => finishNodeDrag(event.clientX, event.clientY); + const cancelNodeDrag = () => finishNodeDrag(); + window.addEventListener("mousemove", handleGlobalMouseMove); + window.addEventListener("mouseup", handleGlobalMouseUp); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", cancelNodeDrag); + window.addEventListener("blur", cancelNodeDrag); + window.addEventListener("pointermove", handleGlobalPointerMove); + return () => { + window.removeEventListener("mousemove", handleGlobalMouseMove); + window.removeEventListener("mouseup", handleGlobalMouseUp); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", cancelNodeDrag); + window.removeEventListener("blur", cancelNodeDrag); + window.removeEventListener("pointermove", handleGlobalPointerMove); + }; + }, [finishNodeDrag, handleGlobalMouseMove, handleGlobalMouseUp, handleGlobalPointerMove]); + + const createImageFileNode = useCallback(async (file: File, position: Position) => { const image = await uploadImage(file); const size = fitImageNodeSize(image.width, image.height); - setNodes((prev) => - prev.map((node) => - node.id === target.nodeId - ? { ...node, type: CanvasNodeType.Image, title: file.name, width: size.width, height: size.height, metadata: { ...node.metadata, ...imageMetadata(image), errorDetails: undefined, freeResize: false, isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined, batchUsesReferenceImages: undefined, generationType: undefined, model: undefined, size: undefined, quality: undefined, count: undefined, references: undefined, primaryImageId: undefined, imageBatchExpanded: undefined } } - : node, - ), - ); - setSelectedNodeIds(new Set([target.nodeId])); + const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const newNode: CanvasNodeData = { + id, + type: CanvasNodeType.Image, + title: file.name, + position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, + width: size.width, + height: size.height, + metadata: imageMetadata(image), + }; + + setNodes((prev) => [...prev, newNode]); + setSelectedNodeIds(new Set([id])); setSelectedConnectionId(null); - setDialogNodeId(target.nodeId); - } else { - const position = target?.position || screenToCanvas( - (containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, - (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2, - ); - void createImageFileNode(file, position); - } + setDialogNodeId(id); + }, []); - uploadTargetRef.current = null; - event.target.value = ""; - }, - [createImageFileNode, screenToCanvas, size.height, size.width], - ); + const createTextNodeFromClipboard = useCallback( + (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return false; - const handleDrop = useCallback( - (event: ReactDragEvent) => { - event.preventDefault(); - const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); - if (!file) return; + const node = { + ...createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }), + title: trimmed.slice(0, 32) || "剪切板文本", + }; - const pos = screenToCanvas(event.clientX, event.clientY); - void createImageFileNode(file, pos); - }, - [createImageFileNode, screenToCanvas], - ); - - const pasteAssistantImage = useCallback((file: File) => { - const position = screenToCanvas( - (containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, - (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2, - ); - void createImageFileNode(file, position); - message.success("已从剪切板添加图片"); - }, [createImageFileNode, message, screenToCanvas, size.height, size.width]); - - const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => { - setChatSessions(sessions); - setActiveChatId(activeId); - }, []); - - const startTitleEditing = useCallback(() => { - setTitleDraft(currentProject?.title || "未命名画布"); - setTitleEditing(true); - }, [currentProject?.title]); - - const finishTitleEditing = useCallback(() => { - const nextTitle = titleDraft.trim(); - if (nextTitle) renameProject(projectId, nextTitle); - setTitleEditing(false); - }, [projectId, renameProject, titleDraft]); - - const preventCanvasContextMenu = useCallback((event: ReactMouseEvent) => { - if ((event.target as HTMLElement).closest("[data-node-id]")) return; - event.preventDefault(); - setContextMenu(null); - }, []); - - const handleGenerateNode = useCallback( - async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => { - const sourceNode = nodesRef.current.find((node) => node.id === nodeId); - const generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode); - if (!isAiConfigReady(generationConfig, generationConfig.model)) { - openConfigDialog(true); - return; - } - - setRunningNodeId(nodeId); - const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; - const editingTextNode = mode === "text" && Boolean(sourceTextContent); - const generationContext = await hydrateNodeGenerationContext(buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt)); - const effectivePrompt = generationContext.prompt.trim(); - const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode; - if (!effectivePrompt && mode === "text") { - setRunningNodeId(null); - return; - } - let pendingChildIds: string[] = []; - if (markSourceStatus) setNodes((prev) => prev.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)); - - try { - if (mode === "image") { - const count = getGenerationCount(generationConfig.count); - const isConfigNode = sourceNode?.type === CanvasNodeType.Config; - const isImageNode = sourceNode?.type === CanvasNodeType.Image; - const isEmptyImageNode = isImageNode && !sourceNode?.metadata?.content; - const sourceReference = isImageNode && sourceNode?.metadata?.content - ? [{ id: sourceNode.id, name: `${sourceNode.title || sourceNode.id}.png`, type: sourceNode.metadata.mimeType || "image/png", dataUrl: sourceNode.metadata.content, storageKey: sourceNode.metadata.storageKey }] - : []; - const referenceImages = sourceReference.length ? sourceReference : generationContext.referenceImages; - const generationType = referenceImages.length ? "edit" as const : "generation" as const; - const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages); - const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text]; - const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; - const parentPosition = sourceNode?.position || { x: 0, y: 0 }; - const gap = 96; - const rowGap = 36; - const rootId = isEmptyImageNode ? nodeId : nanoid(); - const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; - const targetIds = count > 1 ? childIds : [rootId]; - pendingChildIds = isEmptyImageNode ? childIds : [rootId, ...childIds]; - const rootNode: CanvasNodeData = { - id: rootId, - type: CanvasNodeType.Image, - title: effectivePrompt.slice(0, 32) || "Generated Image", - position: { - x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + gap, - y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2, - }, - width: isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width, - height: isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height, - metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, isBatchRoot: count > 1, batchChildIds: count > 1 ? childIds : undefined, batchUsesReferenceImages: referenceImages.length > 0, ...generationMetadata, imageBatchExpanded: count > 1 ? true : undefined }, - }; - const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ - id, - type: CanvasNodeType.Image, - title: effectivePrompt.slice(0, 32) || "Generated Image", - position: { - x: rootNode.position.x + rootNode.width + 120 + (index % 2) * (imageConfig.width + 36), - y: rootNode.position.y + Math.floor(index / 2) * (imageConfig.height + rowGap), - }, - width: imageConfig.width, - height: imageConfig.height, - metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata }, - })); - const batchConnections = [ - ...(isEmptyImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), - ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId })), - ]; - - setNodes((prev) => [ - ...prev.map((node) => - node.id === nodeId - ? isConfigNode ? { - ...node, - metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined }, - } : isEmptyImageNode ? { - ...node, - position: rootNode.position, - width: rootNode.width, - height: rootNode.height, - title: rootNode.title, - metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined }, - } : isImageNode ? { - ...node, - metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined }, - } : { - ...node, - type: CanvasNodeType.Text, - title: prompt.slice(0, 32) || "Prompt", - width: parentConfig.width, - height: parentConfig.height, - metadata: { ...node.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined }, - } - : node, - ), - ...isEmptyImageNode ? [] : [rootNode], - ...childNodes, - ]); - setConnections((prev) => [...prev, ...batchConnections]); - setSelectedNodeIds(new Set([nodeId])); - setSelectedConnectionId(null); - setDialogNodeId(nodeId); - - let hasSuccess = false; - let hasFailure = false; - await Promise.all(targetIds.map(async (targetId) => { - try { - const image = referenceImages.length - ? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages).then((items) => items[0]) - : await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt).then((items) => items[0]); - const uploaded = await uploadImage(image.dataUrl); - const imageSize = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); - setNodes((prev) => { - const root = prev.find((node) => node.id === rootId); - return prev.map((node) => { - if (node.id !== targetId && node.id !== rootId) return node; - const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 }; - if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId)) return { ...node, position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, width: imageSize.width, height: imageSize.height, metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId } }; - if (node.id === targetId) return { ...node, position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, width: imageSize.width, height: imageSize.height, metadata: { ...node.metadata, ...imageMetadata(uploaded) } }; - return node; - }); - }); - hasSuccess = true; - if (isConfigNode) setNodes((prev) => prev.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)); - return true; - } catch (error) { - const errorDetails = error instanceof Error ? error.message : "生成失败"; - hasFailure = true; - setNodes((prev) => prev.map((node) => node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)); - return false; - } - })); - if (hasFailure) message.error(hasSuccess ? "部分图片生成失败" : "全部图片生成失败"); - setNodes((prev) => prev.map((node) => - node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } } - : node.id === nodeId && isEmptyImageNode ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } } - : node.id === rootId && !hasSuccess ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } } - : node, - )); - return; - } - - let streamed = ""; - const isConfigNode = sourceNode?.type === CanvasNodeType.Config; - const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1; - const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text]; - const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text]; - const parentPosition = sourceNode?.position || { x: 0, y: 0 }; - const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => nanoid()) : []; - pendingChildIds = childIds; - if (isConfigNode || editingTextNode) { - const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ - id, - type: CanvasNodeType.Text, - title: prompt.slice(0, 32) || "Generated Text", - position: { - x: parentPosition.x + parentConfig.width + 96, - y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36), - }, - width: textConfig.width, - height: textConfig.height, - metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 }, - })); - setNodes((prev) => [...prev.map((node) => node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node), ...childNodes]); - setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]); - } - - const answers = await Promise.all( - (childIds.length ? childIds : [nodeId]).map((targetNodeId) => { - let localStreamed = ""; - return requestImageQuestion(generationConfig, buildNodeChatMessages({ ...generationContext, prompt: effectivePrompt }), (text) => { - localStreamed = text; - streamed = text; - if (isConfigNode) return; - setNodes((prev) => prev.map((node) => node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node)); - }).then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed })); - }), - ); - const answerByNodeId = new Map(answers.map((item) => [item.nodeId, item.content])); - setNodes((prev) => - prev.map((node) => - childIds.includes(node.id) ? { ...node, metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } } - : node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } - : node.id === nodeId && !editingTextNode ? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } } - : node, - ), - ); - } catch (error) { - const errorDetails = error instanceof Error ? error.message : "生成失败"; - message.error(errorDetails); - setNodes((prev) => - prev.map((node) => - node.id === nodeId || pendingChildIds.includes(node.id) - ? node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } - : node, - ), - ); - } finally { - setRunningNodeId(null); - } - }, - [effectiveConfig, openConfigDialog], - ); - - const handleRetryNode = useCallback( - async (node: CanvasNodeData) => { - const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node; - const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null; - const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined; - const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType); - const generationConfig = hasSavedImageMetadata && savedImageMetadata - ? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, count: "1" } - : { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" }; - if (!isAiConfigReady(generationConfig, generationConfig.model)) { - openConfigDialog(true); - return; - } - - const context = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || "")); - const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim(); - if (!prompt) { - message.warning("找不到提示词,无法重试"); - return; - } - const generationType = savedImageMetadata?.generationType; - const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length); - const retryReferenceImages = hasSavedImageMetadata && savedImageMetadata - ? await resolveMetadataReferences(savedImageMetadata) - : useReferenceImages - ? context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode) - : []; - if (useReferenceImages && !retryReferenceImages) { - message.error("参考图片已丢失,无法继续重试"); - setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item)); - return; - } - - setRunningNodeId(node.id); - setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item)); - - try { - if (node.type === CanvasNodeType.Text) { - if (!context) return; - let streamed = ""; - const answer = await requestImageQuestion(generationConfig, buildNodeChatMessages({ ...context, prompt }), (text) => { - streamed = text; - setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item)); - }); - setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item)); - return; - } - - const image = useReferenceImages - ? await requestEdit(generationConfig, prompt, retryReferenceImages).then((items) => items[0]) - : await requestGeneration(generationConfig, prompt).then((items) => items[0]); - const uploadedImage = await uploadImage(image.dataUrl); - const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; - const imageSize = fitImageNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height); - const generationMetadata = savedImageMetadata?.generationType - ? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references } - : buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryReferenceImages || []); - setNodes((prev) => prev.map((item) => item.id === node.id ? { - ...item, - type: CanvasNodeType.Image, - width: imageSize.width, - height: imageSize.height, - metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata }, - } : item)); - } catch (error) { - const errorDetails = error instanceof Error ? error.message : "生成失败"; - message.error(errorDetails); - setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)); - } finally { - setRunningNodeId(null); - } - }, - [effectiveConfig, message, openConfigDialog], - ); - - const generateImageFromTextNode = useCallback((node: CanvasNodeData) => { - const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim(); - if (!prompt) { - message.warning("文本节点为空,无法生图"); - return; - } - const sourceNode = nodesRef.current.find((item) => item.id === node.id); - if (!sourceNode) return; - const nodeSize = getNodeSpec(CanvasNodeType.Config); - const configNode = createCanvasNode(CanvasNodeType.Config, { - x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2, - y: sourceNode.position.y + sourceNode.height / 2, - }, { - prompt: "", - model: effectiveConfig.imageModel || effectiveConfig.model, - size: effectiveConfig.size, - count: 3, - }); - const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id }; - const nextNodes = nodesRef.current.map((item) => item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS } } : item).concat(configNode); - const nextConnections = [...connectionsRef.current, connection]; - nodesRef.current = nextNodes; - connectionsRef.current = nextConnections; - setNodes(nextNodes); - setConnections(nextConnections); - setSelectedNodeIds(new Set([configNode.id])); - setSelectedConnectionId(null); - setDialogNodeId(configNode.id); - }, [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message]); - - const insertAssistantImage = useCallback(async (image: CanvasAssistantImage) => { - const storedImage = image.storageKey - ? { url: image.dataUrl, storageKey: image.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } - : await uploadImage(image.dataUrl); - const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage; - const config = fitImageNodeSize(meta.width, meta.height); - const center = screenToCanvas( - (containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, - (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2, - ); - const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const node: CanvasNodeData = { - id, - type: CanvasNodeType.Image, - title: image.prompt.slice(0, 32) || "Generated Image", - position: { x: center.x - config.width / 2, y: center.y - config.height / 2 }, - width: config.width, - height: config.height, - metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: image.prompt }, - }; - - setNodes((prev) => [...prev, node]); - setSelectedNodeIds(new Set([id])); - setSelectedConnectionId(null); - setDialogNodeId(id); - }, [screenToCanvas, size.height, size.width]); - - const insertAssistantText = useCallback((text: string) => { - const center = screenToCanvas( - (containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, - (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2, - ); - const node = { - ...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }), - title: text.slice(0, 32) || "Assistant Text", - }; - - setNodes((prev) => [...prev, node]); - setSelectedNodeIds(new Set([node.id])); - setSelectedConnectionId(null); - }, [screenToCanvas, size.height, size.width]); - - const handleAssetInsert = useCallback((payload: InsertAssetPayload) => { - if (payload.kind === "text") { - insertAssistantText(payload.content); - } else { - insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey }); - } - setAssetPickerOpen(false); - }, [insertAssistantImage, insertAssistantText]); - - if (!projectLoaded) return ; - - return ( -
-
- setTitleEditing(false)} - canUndo={historyState.canUndo} - canRedo={historyState.canRedo} - onHome={() => router.push("/")} - onProjects={() => router.push("/canvas")} - onCreateProject={createAndOpenProject} - onDeleteProject={deleteCurrentProject} - onImportImage={() => handleUploadRequest()} - onUndo={undoCanvas} - onRedo={redoCanvas} - assistantCollapsed={assistantCollapsed} - onExpandAssistant={() => { - setAssistantMounted(true); - setAssistantCollapsed(false); - }} - /> - - { - setViewport(next); + setNodes((prev) => [...prev, node]); + setSelectedNodeIds(new Set([node.id])); + setSelectedConnectionId(null); setContextMenu(null); - }} - onCanvasMouseDown={handleCanvasMouseDown} - onCanvasDeselect={deselectCanvas} - onContextMenu={preventCanvasContextMenu} - onDrop={handleDrop} - > - - {connections.filter((connection) => { - const from = nodeById.get(connection.fromNodeId); - const to = nodeById.get(connection.toNodeId); - return Boolean(from && to && !isHiddenBatchConnectionEndpoint(from, nodes) && !isHiddenBatchConnectionEndpoint(to, nodes)); - }).map((connection) => { - const from = nodeById.get(connection.fromNodeId); - const to = nodeById.get(connection.toNodeId); - if (!from || !to) return null; + setDialogNodeId(node.id); + return true; + }, + [getCanvasCenter], + ); - return ( - { - setSelectedConnectionId(connection.id); - setSelectedNodeIds(new Set()); - setContextMenu(null); - }} - /> - ); - })} - {connectingParams ? : null} - + const pasteSystemClipboard = useCallback(async () => { + if (!navigator.clipboard) return; - {visibleNodes.map((node) => ( - ( - { - setNodeImageSettingsOpen(open); - if (open) setToolbarNodeId(null); - }} - /> - )} - renderNodeContent={(contentNode) => ( - { - const target = nodesRef.current.find((item) => item.id === nodeId); - void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || ""); - }} - /> - )} - onMouseDown={handleNodeMouseDown} - onHoverStart={(nodeId) => { - if (nodeDraggingRef.current) return; - setHoveredNodeId(nodeId); - keepNodeToolbar(nodeId); - }} - onHoverEnd={(nodeId) => { - setHoveredNodeId((current) => current === nodeId ? null : current); - hideNodeToolbar(); - }} - onConnectStart={handleConnectStart} - onResize={handleNodeResize} - onContentChange={handleNodeContentChange} - onToggleBatch={toggleBatchExpanded} - onSetBatchPrimary={setBatchPrimary} - onRetry={(node) => void handleRetryNode(node)} - onGenerateImage={generateImageFromTextNode} - onContextMenu={(event, id) => { + const items = await navigator.clipboard.read(); + const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/"))); + if (imageItem) { + const imageType = imageItem.types.find((type) => type.startsWith("image/")); + if (!imageType) return; + const blob = await imageItem.getType(imageType); + const file = new File([blob], "clipboard-image.png", { type: imageType }); + void createImageFileNode(file, getCanvasCenter()); + message.success("已从剪切板添加图片"); + return; + } + + const text = await navigator.clipboard.readText(); + if (createTextNodeFromClipboard(text)) message.success("已从剪切板添加文本"); + }, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return; + + const key = event.key.toLowerCase(); + const isModifierShortcut = event.metaKey || event.ctrlKey; + + if (isModifierShortcut && !event.altKey && key === "z") { event.preventDefault(); - event.stopPropagation(); - setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id }); - }} - /> - ))} + if (event.shiftKey) redoCanvas(); + else undoCanvas(); + return; + } - {selectionBox ? ( -
- ) : null} - {pendingConnectionCreate ? ( - createConnectedNode(type, pendingConnectionCreate)} - onClose={cancelPendingConnectionCreate} - /> - ) : null} - + if (isModifierShortcut && !event.altKey && key === "y") { + event.preventDefault(); + redoCanvas(); + return; + } - setInfoNodeId(node.id)} - onEditText={openTextEditor} - onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} - onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} - onToggleDialog={(node) => setDialogNodeId((current) => current === node.id ? null : node.id)} - onGenerateImage={generateImageFromTextNode} - onUpload={(node) => handleUploadRequest(node.id)} - onDownload={downloadNodeImage} - onSaveAsset={(node) => void saveNodeAsset(node)} - onCrop={(node) => setCropNodeId(node.id)} - onAngle={(node) => setAngleNodeId(node.id)} - onRetry={(node) => void handleRetryNode(node)} - onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} - onDelete={(node) => deleteNodes(new Set([node.id]))} - /> + if (isModifierShortcut && !event.altKey && key === "a") { + event.preventDefault(); + setSelectedNodeIds(new Set(nodesRef.current.map((node) => node.id))); + setSelectedConnectionId(null); + setContextMenu(null); + setSelectionBox(null); + return; + } - createNode(CanvasNodeType.Image)} - onAddText={() => createNode(CanvasNodeType.Text)} - onAddConfig={() => createNode(CanvasNodeType.Config)} - onUndo={undoCanvas} - onRedo={redoCanvas} - onUpload={() => handleUploadRequest()} - onDelete={() => deleteNodes(new Set(selectedNodeIds))} - onClear={() => setClearConfirmOpen(true)} - onDeselect={deselectCanvas} - onBackgroundModeChange={setBackgroundMode} - onOpenAssetLibrary={() => { setAssetPickerTab("library"); setAssetPickerOpen(true); }} - onOpenMyAssets={() => { setAssetPickerTab("my-assets"); setAssetPickerOpen(true); }} - /> + if (isModifierShortcut && !event.altKey && key === "c") { + event.preventDefault(); + copySelectedNodes(); + return; + } - {isMiniMapOpen ? : null} + if (isModifierShortcut && !event.altKey && key === "v") { + event.preventDefault(); + if (!pasteCopiedNodes()) void pasteSystemClipboard(); + return; + } - setIsMiniMapOpen((value) => !value)} - /> + if (event.key === "Delete" || event.key === "Backspace") { + if (selectedNodeIdsRef.current.size) { + deleteNodes(new Set(selectedNodeIdsRef.current)); + } else if (selectedConnectionId) { + setConnections((prev) => prev.filter((conn) => conn.id !== selectedConnectionId)); + setSelectedConnectionId(null); + } + } - {contextMenu ? ( - setContextMenu(null)} - onDuplicate={() => { - duplicateNode(contextMenu.nodeId); - setContextMenu(null); - }} - onDelete={() => { - deleteNodes(new Set([contextMenu.nodeId])); - setContextMenu(null); - }} - /> - ) : null} + if (event.key === "Escape") { + setSelectedNodeIds(new Set()); + setSelectedConnectionId(null); + setContextMenu(null); + setSelectionBox(null); + setConnecting(null); + setHoveredNodeId(null); + setToolbarNodeId(null); + setDialogNodeId(null); + setEditingNodeId(null); + setInfoNodeId(null); + setCropNodeId(null); + setPendingConnectionCreate(null); + } + }; - + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [copySelectedNodes, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]); - setInfoNodeId(null)} /> + const handleConnectStart = useCallback( + (event: ReactMouseEvent, nodeId: string, handleType: "source" | "target") => { + event.stopPropagation(); + setMouseWorld(screenToCanvas(event.clientX, event.clientY)); + setConnecting({ nodeId, handleType }); + connectionTargetNodeIdRef.current = null; + setConnectionTargetNodeId(null); + setSelectedConnectionId(null); + }, + [screenToCanvas, setConnecting], + ); - {cropNode?.metadata?.content ? ( - setCropNodeId(null)} - onConfirm={(crop) => void cropImageNode(cropNode!, crop)} - /> - ) : null} + const handleNodeResize = useCallback((nodeId: string, width: number, height: number, position?: Position) => { + setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, width, height, position: position || node.position } : node))); + }, []); - {angleNode?.metadata?.content ? ( - setAngleNodeId(null)} - onConfirm={(params) => void generateAngleNode(angleNode!, params)} - /> - ) : null} + const toggleNodeFreeResize = useCallback((nodeId: string) => { + setNodes((prev) => + prev.map((node) => { + if (node.id !== nodeId) return node; + const freeResize = !node.metadata?.freeResize; + if (freeResize || node.type !== CanvasNodeType.Image) return { ...node, metadata: { ...node.metadata, freeResize } }; + const ratio = (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1); + const height = node.width / ratio; + return { ...node, height, position: { x: node.position.x, y: node.position.y + node.height / 2 - height / 2 }, metadata: { ...node.metadata, freeResize } }; + }), + ); + }, []); - setClearConfirmOpen(false)} - footer={ - <> - - - - } - > -

这会删除当前画布上的所有节点和连线。

-
+ const handleNodeContentChange = useCallback((nodeId: string, content: string) => { + setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node))); + }, []); - setAssetPickerOpen(false)} - /> -
- {assistantMounted ? ( - setAssistantCollapsed(true)} - onCollapse={() => setAssistantMounted(false)} - /> - ) : null} -
- ); + const toggleBatchExpanded = useCallback((nodeId: string) => { + const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded); + if (isExpanded) { + setCollapsingBatchIds((prev) => new Set(prev).add(nodeId)); + window.setTimeout(() => { + setCollapsingBatchIds((prev) => { + const next = new Set(prev); + next.delete(nodeId); + return next; + }); + }, 320); + } else { + setOpeningBatchIds((prev) => new Set(prev).add(nodeId)); + window.setTimeout(() => { + setOpeningBatchIds((prev) => { + const next = new Set(prev); + next.delete(nodeId); + return next; + }); + }, 260); + } + setNodes((prev) => + prev.map((node) => { + if (node.id !== nodeId) return node; + return { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } }; + }), + ); + }, []); + + const setBatchPrimary = useCallback((child: CanvasNodeData) => { + const rootId = child.metadata?.batchRootId; + if (!rootId || !child.metadata?.content) return; + setNodes((prev) => + prev.map((node) => + node.id === rootId + ? { + ...node, + width: child.width, + height: child.height, + metadata: { + ...node.metadata, + content: child.metadata?.content, + primaryImageId: child.id, + naturalWidth: child.metadata?.naturalWidth, + naturalHeight: child.metadata?.naturalHeight, + freeResize: child.metadata?.freeResize, + }, + } + : node, + ), + ); + }, []); + + const openTextEditor = useCallback((node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Text) return; + setSelectedNodeIds(new Set([node.id])); + setSelectedConnectionId(null); + setDialogNodeId(node.id); + setEditingNodeId(node.id); + setEditRequestNonce((value) => value + 1); + }, []); + + const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => { + setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node))); + }, []); + + const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial) => { + setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(patch || {}) } } : node))); + }, []); + + const downloadNodeImage = useCallback((node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return; + const link = document.createElement("a"); + link.href = node.metadata.content; + link.download = `canvas-image-${node.id}.${imageExtension(node.metadata.content)}`; + link.click(); + }, []); + + const saveNodeAsset = useCallback( + async (node: CanvasNodeData) => { + if (node.type === CanvasNodeType.Text) { + const content = node.metadata?.content?.trim(); + if (!content) return message.error("没有可保存的文本"); + addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } }); + message.success("已加入我的素材"); + return; + } + if (!node.metadata?.content) return message.error("没有可保存的图片"); + const dataUrl = node.metadata.storageKey ? "" : node.metadata.content; + addAsset({ + kind: "image", + title: node.metadata?.prompt?.slice(0, 24) || "画布图片", + coverUrl: node.metadata.content, + tags: [], + source: "Canvas", + data: { + dataUrl, + storageKey: node.metadata.storageKey, + width: node.metadata.naturalWidth || node.width, + height: node.metadata.naturalHeight || node.height, + bytes: node.metadata.bytes || getDataUrlByteSize(dataUrl), + mimeType: node.metadata.mimeType || "image/png", + }, + metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt }, + }); + message.success("已加入我的素材"); + }, + [addAsset, message], + ); + + const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => { + if (!node.metadata?.content) return; + const cropped = await cropDataUrl(node.metadata.content, crop); + const image = await uploadImage(cropped); + const width = Math.min(node.width, Math.max(220, image.width)); + const childId = nanoid(); + const child: CanvasNodeData = { + id: childId, + type: CanvasNodeType.Image, + title: "Cropped Image", + position: { x: node.position.x + node.width + 96, y: node.position.y }, + width, + height: width * (image.height / image.width), + metadata: { + ...imageMetadata(image), + prompt: node.metadata?.prompt, + }, + }; + setNodes((prev) => [...prev, child]); + setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); + setSelectedNodeIds(new Set([childId])); + setDialogNodeId(childId); + setCropNodeId(null); + }, []); + + const generateAngleNode = useCallback( + async (node: CanvasNodeData, params: CanvasImageAngleParams) => { + if (!node.metadata?.content) return; + const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" }; + if (!isAiConfigReady(generationConfig, generationConfig.model)) { + openConfigDialog(true); + return; + } + const childId = nanoid(); + const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const title = buildAngleLabel(params); + const prompt = buildAnglePrompt(params); + const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [ + { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }, + ]); + setAngleNodeId(null); + setRunningNodeId(childId); + setNodes((prev) => [ + ...prev, + { + id: childId, + type: CanvasNodeType.Image, + title, + position: { x: node.position.x + node.width + 96, y: node.position.y }, + width: imageConfig.width, + height: imageConfig.height, + metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata }, + }, + ]); + setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); + setSelectedNodeIds(new Set([childId])); + setDialogNodeId(childId); + try { + const image = await requestEdit(generationConfig, prompt, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }]).then( + (items) => items[0], + ); + const uploaded = await uploadImage(image.dataUrl); + const size = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); + setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item))); + } catch (error) { + const errorDetails = error instanceof Error ? error.message : "生成失败"; + setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); + } finally { + setRunningNodeId(null); + } + }, + [effectiveConfig, openConfigDialog], + ); + + const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => { + setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, fontSize } } : node))); + }, []); + + const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => { + uploadTargetRef.current = { nodeId, position }; + imageInputRef.current?.click(); + }, []); + + const handleImageInputChange = useCallback( + async (event: ReactChangeEvent) => { + const file = event.target.files?.[0]; + const target = uploadTargetRef.current; + if (!file || !file.type.startsWith("image/")) return; + + if (target?.nodeId) { + const image = await uploadImage(file); + const size = fitImageNodeSize(image.width, image.height); + setNodes((prev) => + prev.map((node) => + node.id === target.nodeId + ? { + ...node, + type: CanvasNodeType.Image, + title: file.name, + width: size.width, + height: size.height, + metadata: { + ...node.metadata, + ...imageMetadata(image), + errorDetails: undefined, + freeResize: false, + isBatchRoot: undefined, + batchRootId: undefined, + batchChildIds: undefined, + batchUsesReferenceImages: undefined, + generationType: undefined, + model: undefined, + size: undefined, + quality: undefined, + count: undefined, + references: undefined, + primaryImageId: undefined, + imageBatchExpanded: undefined, + }, + } + : node, + ), + ); + setSelectedNodeIds(new Set([target.nodeId])); + setSelectedConnectionId(null); + setDialogNodeId(target.nodeId); + } else { + const position = target?.position || screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); + void createImageFileNode(file, position); + } + + uploadTargetRef.current = null; + event.target.value = ""; + }, + [createImageFileNode, screenToCanvas, size.height, size.width], + ); + + const handleDrop = useCallback( + (event: ReactDragEvent) => { + event.preventDefault(); + const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); + if (!file) return; + + const pos = screenToCanvas(event.clientX, event.clientY); + void createImageFileNode(file, pos); + }, + [createImageFileNode, screenToCanvas], + ); + + const pasteAssistantImage = useCallback( + (file: File) => { + const position = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); + void createImageFileNode(file, position); + message.success("已从剪切板添加图片"); + }, + [createImageFileNode, message, screenToCanvas, size.height, size.width], + ); + + const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => { + setChatSessions(sessions); + setActiveChatId(activeId); + }, []); + + const startTitleEditing = useCallback(() => { + setTitleDraft(currentProject?.title || "未命名画布"); + setTitleEditing(true); + }, [currentProject?.title]); + + const finishTitleEditing = useCallback(() => { + const nextTitle = titleDraft.trim(); + if (nextTitle) renameProject(projectId, nextTitle); + setTitleEditing(false); + }, [projectId, renameProject, titleDraft]); + + const preventCanvasContextMenu = useCallback((event: ReactMouseEvent) => { + if ((event.target as HTMLElement).closest("[data-node-id]")) return; + event.preventDefault(); + setContextMenu(null); + }, []); + + const handleGenerateNode = useCallback( + async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => { + const sourceNode = nodesRef.current.find((node) => node.id === nodeId); + const generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode); + if (!isAiConfigReady(generationConfig, generationConfig.model)) { + openConfigDialog(true); + return; + } + + setRunningNodeId(nodeId); + const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; + const editingTextNode = mode === "text" && Boolean(sourceTextContent); + const generationContext = await hydrateNodeGenerationContext( + buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt), + ); + const effectivePrompt = generationContext.prompt.trim(); + const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode; + if (!effectivePrompt && mode === "text") { + setRunningNodeId(null); + return; + } + let pendingChildIds: string[] = []; + if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node))); + + try { + if (mode === "image") { + const count = getGenerationCount(generationConfig.count); + const isConfigNode = sourceNode?.type === CanvasNodeType.Config; + const isImageNode = sourceNode?.type === CanvasNodeType.Image; + const isEmptyImageNode = isImageNode && !sourceNode?.metadata?.content; + const sourceReference = + isImageNode && sourceNode?.metadata?.content + ? [{ id: sourceNode.id, name: `${sourceNode.title || sourceNode.id}.png`, type: sourceNode.metadata.mimeType || "image/png", dataUrl: sourceNode.metadata.content, storageKey: sourceNode.metadata.storageKey }] + : []; + const referenceImages = sourceReference.length ? sourceReference : generationContext.referenceImages; + const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const); + const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages); + const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text]; + const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const parentPosition = sourceNode?.position || { x: 0, y: 0 }; + const gap = 96; + const rowGap = 36; + const rootId = isEmptyImageNode ? nodeId : nanoid(); + const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; + const targetIds = count > 1 ? childIds : [rootId]; + pendingChildIds = isEmptyImageNode ? childIds : [rootId, ...childIds]; + const rootNode: CanvasNodeData = { + id: rootId, + type: CanvasNodeType.Image, + title: effectivePrompt.slice(0, 32) || "Generated Image", + position: { + x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + gap, + y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2, + }, + width: isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width, + height: isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height, + metadata: { + prompt: effectivePrompt, + status: NODE_STATUS_LOADING, + isBatchRoot: count > 1, + batchChildIds: count > 1 ? childIds : undefined, + batchUsesReferenceImages: referenceImages.length > 0, + ...generationMetadata, + imageBatchExpanded: count > 1 ? true : undefined, + }, + }; + const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ + id, + type: CanvasNodeType.Image, + title: effectivePrompt.slice(0, 32) || "Generated Image", + position: { + x: rootNode.position.x + rootNode.width + 120 + (index % 2) * (imageConfig.width + 36), + y: rootNode.position.y + Math.floor(index / 2) * (imageConfig.height + rowGap), + }, + width: imageConfig.width, + height: imageConfig.height, + metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata }, + })); + const batchConnections = [...(isEmptyImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; + + setNodes((prev) => [ + ...prev.map((node) => + node.id === nodeId + ? isConfigNode + ? { + ...node, + metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined }, + } + : isEmptyImageNode + ? { + ...node, + position: rootNode.position, + width: rootNode.width, + height: rootNode.height, + title: rootNode.title, + metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined }, + } + : isImageNode + ? { + ...node, + metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined }, + } + : { + ...node, + type: CanvasNodeType.Text, + title: prompt.slice(0, 32) || "Prompt", + width: parentConfig.width, + height: parentConfig.height, + metadata: { ...node.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined }, + } + : node, + ), + ...(isEmptyImageNode ? [] : [rootNode]), + ...childNodes, + ]); + setConnections((prev) => [...prev, ...batchConnections]); + setSelectedNodeIds(new Set([nodeId])); + setSelectedConnectionId(null); + setDialogNodeId(nodeId); + + let hasSuccess = false; + let hasFailure = false; + await Promise.all( + targetIds.map(async (targetId) => { + try { + const image = referenceImages.length + ? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages).then((items) => items[0]) + : await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt).then((items) => items[0]); + const uploaded = await uploadImage(image.dataUrl); + const imageSize = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); + setNodes((prev) => { + const root = prev.find((node) => node.id === rootId); + return prev.map((node) => { + if (node.id !== targetId && node.id !== rootId) return node; + const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 }; + if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId)) + return { + ...node, + position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, + width: imageSize.width, + height: imageSize.height, + metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId }, + }; + if (node.id === targetId) + return { + ...node, + position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, + width: imageSize.width, + height: imageSize.height, + metadata: { ...node.metadata, ...imageMetadata(uploaded) }, + }; + return node; + }); + }); + hasSuccess = true; + if (isConfigNode) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node))); + return true; + } catch (error) { + const errorDetails = error instanceof Error ? error.message : "生成失败"; + hasFailure = true; + setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node))); + return false; + } + }), + ); + if (hasFailure) message.error(hasSuccess ? "部分图片生成失败" : "全部图片生成失败"); + setNodes((prev) => + prev.map((node) => + node.id === nodeId && isConfigNode + ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } } + : node.id === nodeId && isEmptyImageNode + ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } } + : node.id === rootId && !hasSuccess + ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } } + : node, + ), + ); + return; + } + + let streamed = ""; + const isConfigNode = sourceNode?.type === CanvasNodeType.Config; + const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1; + const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text]; + const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text]; + const parentPosition = sourceNode?.position || { x: 0, y: 0 }; + const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => nanoid()) : []; + pendingChildIds = childIds; + if (isConfigNode || editingTextNode) { + const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ + id, + type: CanvasNodeType.Text, + title: prompt.slice(0, 32) || "Generated Text", + position: { + x: parentPosition.x + parentConfig.width + 96, + y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36), + }, + width: textConfig.width, + height: textConfig.height, + metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 }, + })); + setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]); + setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]); + } + + const answers = await Promise.all( + (childIds.length ? childIds : [nodeId]).map((targetNodeId) => { + let localStreamed = ""; + return requestImageQuestion(generationConfig, buildNodeChatMessages({ ...generationContext, prompt: effectivePrompt }), (text) => { + localStreamed = text; + streamed = text; + if (isConfigNode) return; + setNodes((prev) => prev.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node))); + }).then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed })); + }), + ); + const answerByNodeId = new Map(answers.map((item) => [item.nodeId, item.content])); + setNodes((prev) => + prev.map((node) => + childIds.includes(node.id) + ? { ...node, metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } } + : node.id === nodeId && isConfigNode + ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } + : node.id === nodeId && !editingTextNode + ? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } } + : node, + ), + ); + } catch (error) { + const errorDetails = error instanceof Error ? error.message : "生成失败"; + message.error(errorDetails); + setNodes((prev) => + prev.map((node) => (node.id === nodeId || pendingChildIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } }) : node)), + ); + } finally { + setRunningNodeId(null); + } + }, + [effectiveConfig, openConfigDialog], + ); + + const handleRetryNode = useCallback( + async (node: CanvasNodeData) => { + const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node; + const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null; + const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined; + const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType); + const generationConfig = + hasSavedImageMetadata && savedImageMetadata + ? { + ...effectiveConfig, + model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, + quality: savedImageMetadata.quality || effectiveConfig.quality, + size: savedImageMetadata.size || effectiveConfig.size, + count: "1", + } + : { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" }; + if (!isAiConfigReady(generationConfig, generationConfig.model)) { + openConfigDialog(true); + return; + } + + const context = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || "")); + const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim(); + if (!prompt) { + message.warning("找不到提示词,无法重试"); + return; + } + const generationType = savedImageMetadata?.generationType; + const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length); + const retryReferenceImages = + hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : []; + if (useReferenceImages && !retryReferenceImages) { + message.error("参考图片已丢失,无法继续重试"); + setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item))); + return; + } + + setRunningNodeId(node.id); + setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item))); + + try { + if (node.type === CanvasNodeType.Text) { + if (!context) return; + let streamed = ""; + const answer = await requestImageQuestion(generationConfig, buildNodeChatMessages({ ...context, prompt }), (text) => { + streamed = text; + setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item))); + }); + setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item))); + return; + } + + const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryReferenceImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]); + const uploadedImage = await uploadImage(image.dataUrl); + const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const imageSize = fitImageNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height); + const generationMetadata = savedImageMetadata?.generationType + ? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references } + : buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryReferenceImages || []); + setNodes((prev) => + prev.map((item) => + item.id === node.id + ? { + ...item, + type: CanvasNodeType.Image, + width: imageSize.width, + height: imageSize.height, + metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata }, + } + : item, + ), + ); + } catch (error) { + const errorDetails = error instanceof Error ? error.message : "生成失败"; + message.error(errorDetails); + setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); + } finally { + setRunningNodeId(null); + } + }, + [effectiveConfig, message, openConfigDialog], + ); + + const generateImageFromTextNode = useCallback( + (node: CanvasNodeData) => { + const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim(); + if (!prompt) { + message.warning("文本节点为空,无法生图"); + return; + } + const sourceNode = nodesRef.current.find((item) => item.id === node.id); + if (!sourceNode) return; + const nodeSize = getNodeSpec(CanvasNodeType.Config); + const configNode = createCanvasNode( + CanvasNodeType.Config, + { + x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2, + y: sourceNode.position.y + sourceNode.height / 2, + }, + { + prompt: "", + model: effectiveConfig.imageModel || effectiveConfig.model, + size: effectiveConfig.size, + count: 3, + }, + ); + const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id }; + const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(configNode); + const nextConnections = [...connectionsRef.current, connection]; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + setSelectedNodeIds(new Set([configNode.id])); + setSelectedConnectionId(null); + setDialogNodeId(configNode.id); + }, + [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message], + ); + + const insertAssistantImage = useCallback( + async (image: CanvasAssistantImage) => { + const storedImage = image.storageKey ? { url: image.dataUrl, storageKey: image.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(image.dataUrl); + const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage; + const config = fitImageNodeSize(meta.width, meta.height); + const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); + const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const node: CanvasNodeData = { + id, + type: CanvasNodeType.Image, + title: image.prompt.slice(0, 32) || "Generated Image", + position: { x: center.x - config.width / 2, y: center.y - config.height / 2 }, + width: config.width, + height: config.height, + metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: image.prompt }, + }; + + setNodes((prev) => [...prev, node]); + setSelectedNodeIds(new Set([id])); + setSelectedConnectionId(null); + setDialogNodeId(id); + }, + [screenToCanvas, size.height, size.width], + ); + + const insertAssistantText = useCallback( + (text: string) => { + const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); + const node = { + ...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }), + title: text.slice(0, 32) || "Assistant Text", + }; + + setNodes((prev) => [...prev, node]); + setSelectedNodeIds(new Set([node.id])); + setSelectedConnectionId(null); + }, + [screenToCanvas, size.height, size.width], + ); + + const handleAssetInsert = useCallback( + (payload: InsertAssetPayload) => { + if (payload.kind === "text") { + insertAssistantText(payload.content); + } else { + insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey }); + } + setAssetPickerOpen(false); + }, + [insertAssistantImage, insertAssistantText], + ); + + if (!projectLoaded) return ; + + return ( +
+
+ setTitleEditing(false)} + canUndo={historyState.canUndo} + canRedo={historyState.canRedo} + onHome={() => router.push("/")} + onProjects={() => router.push("/canvas")} + onCreateProject={createAndOpenProject} + onDeleteProject={deleteCurrentProject} + onImportImage={() => handleUploadRequest()} + onUndo={undoCanvas} + onRedo={redoCanvas} + assistantCollapsed={assistantCollapsed} + onExpandAssistant={() => { + setAssistantMounted(true); + setAssistantCollapsed(false); + }} + /> + + { + setViewport(next); + setContextMenu(null); + }} + onCanvasMouseDown={handleCanvasMouseDown} + onCanvasDeselect={deselectCanvas} + onContextMenu={preventCanvasContextMenu} + onDrop={handleDrop} + > + + {connections + .filter((connection) => { + const from = nodeById.get(connection.fromNodeId); + const to = nodeById.get(connection.toNodeId); + return Boolean(from && to && !isHiddenBatchConnectionEndpoint(from, nodes) && !isHiddenBatchConnectionEndpoint(to, nodes)); + }) + .map((connection) => { + const from = nodeById.get(connection.fromNodeId); + const to = nodeById.get(connection.toNodeId); + if (!from || !to) return null; + + return ( + { + setSelectedConnectionId(connection.id); + setSelectedNodeIds(new Set()); + setContextMenu(null); + }} + /> + ); + })} + {connectingParams ? : null} + + + {visibleNodes.map((node) => ( + ( + { + setNodeImageSettingsOpen(open); + if (open) setToolbarNodeId(null); + }} + /> + )} + renderNodeContent={(contentNode) => ( + { + const target = nodesRef.current.find((item) => item.id === nodeId); + void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || ""); + }} + /> + )} + onMouseDown={handleNodeMouseDown} + onHoverStart={(nodeId) => { + if (nodeDraggingRef.current) return; + setHoveredNodeId(nodeId); + keepNodeToolbar(nodeId); + }} + onHoverEnd={(nodeId) => { + setHoveredNodeId((current) => (current === nodeId ? null : current)); + hideNodeToolbar(); + }} + onConnectStart={handleConnectStart} + onResize={handleNodeResize} + onContentChange={handleNodeContentChange} + onToggleBatch={toggleBatchExpanded} + onSetBatchPrimary={setBatchPrimary} + onRetry={(node) => void handleRetryNode(node)} + onGenerateImage={generateImageFromTextNode} + onContextMenu={(event, id) => { + event.preventDefault(); + event.stopPropagation(); + setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id }); + }} + /> + ))} + + {selectionBox ? ( +
+ ) : null} + {pendingConnectionCreate ? createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null} + + + setInfoNodeId(node.id)} + onEditText={openTextEditor} + onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} + onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} + onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} + onGenerateImage={generateImageFromTextNode} + onUpload={(node) => handleUploadRequest(node.id)} + onDownload={downloadNodeImage} + onSaveAsset={(node) => void saveNodeAsset(node)} + onCrop={(node) => setCropNodeId(node.id)} + onAngle={(node) => setAngleNodeId(node.id)} + onRetry={(node) => void handleRetryNode(node)} + onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} + onDelete={(node) => deleteNodes(new Set([node.id]))} + /> + + createNode(CanvasNodeType.Image)} + onAddText={() => createNode(CanvasNodeType.Text)} + onAddConfig={() => createNode(CanvasNodeType.Config)} + onUndo={undoCanvas} + onRedo={redoCanvas} + onUpload={() => handleUploadRequest()} + onDelete={() => deleteNodes(new Set(selectedNodeIds))} + onClear={() => setClearConfirmOpen(true)} + onDeselect={deselectCanvas} + onBackgroundModeChange={setBackgroundMode} + onOpenAssetLibrary={() => { + setAssetPickerTab("library"); + setAssetPickerOpen(true); + }} + onOpenMyAssets={() => { + setAssetPickerTab("my-assets"); + setAssetPickerOpen(true); + }} + /> + + {isMiniMapOpen ? : null} + + setIsMiniMapOpen((value) => !value)} /> + + {contextMenu ? ( + setContextMenu(null)} + onDuplicate={() => { + duplicateNode(contextMenu.nodeId); + setContextMenu(null); + }} + onDelete={() => { + deleteNodes(new Set([contextMenu.nodeId])); + setContextMenu(null); + }} + /> + ) : null} + + + + setInfoNodeId(null)} /> + + {cropNode?.metadata?.content ? setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null} + + {angleNode?.metadata?.content ? setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null} + + setClearConfirmOpen(false)} + footer={ + <> + + + + } + > +

这会删除当前画布上的所有节点和连线。

+
+ + setAssetPickerOpen(false)} /> +
+ {assistantMounted ? ( + setAssistantCollapsed(true)} + onCollapse={() => setAssistantMounted(false)} + /> + ) : null} +
+ ); } function CanvasTopBar({ - title, - titleDraft, - isTitleEditing, - onTitleDraftChange, - onStartTitleEditing, - onFinishTitleEditing, - onCancelTitleEditing, - canUndo, - canRedo, - onHome, - onProjects, - onCreateProject, - onDeleteProject, - onImportImage, - onUndo, - onRedo, - assistantCollapsed, - onExpandAssistant, + title, + titleDraft, + isTitleEditing, + onTitleDraftChange, + onStartTitleEditing, + onFinishTitleEditing, + onCancelTitleEditing, + canUndo, + canRedo, + onHome, + onProjects, + onCreateProject, + onDeleteProject, + onImportImage, + onUndo, + onRedo, + assistantCollapsed, + onExpandAssistant, }: { - title: string; - titleDraft: string; - isTitleEditing: boolean; - onTitleDraftChange: (value: string) => void; - onStartTitleEditing: () => void; - onFinishTitleEditing: () => void; - onCancelTitleEditing: () => void; - canUndo: boolean; - canRedo: boolean; - onHome: () => void; - onProjects: () => void; - onCreateProject: () => void; - onDeleteProject: () => void; - onImportImage: () => void; - onUndo: () => void; - onRedo: () => void; - assistantCollapsed: boolean; - onExpandAssistant: () => void; + title: string; + titleDraft: string; + isTitleEditing: boolean; + onTitleDraftChange: (value: string) => void; + onStartTitleEditing: () => void; + onFinishTitleEditing: () => void; + onCancelTitleEditing: () => void; + canUndo: boolean; + canRedo: boolean; + onHome: () => void; + onProjects: () => void; + onCreateProject: () => void; + onDeleteProject: () => void; + onImportImage: () => void; + onUndo: () => void; + onRedo: () => void; + assistantCollapsed: boolean; + onExpandAssistant: () => void; }) { - const colorTheme = useThemeStore((state) => state.theme); - const theme = canvasThemes[colorTheme]; - const titleRef = useRef(null); - const accountRef = useRef(null); - const [shortcutsOpen, setShortcutsOpen] = useState(false); - const [accountOpen, setAccountOpen] = useState(false); + const colorTheme = useThemeStore((state) => state.theme); + const theme = canvasThemes[colorTheme]; + const titleRef = useRef(null); + const accountRef = useRef(null); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [accountOpen, setAccountOpen] = useState(false); - useEffect(() => { - if (!isTitleEditing) return; - const close = (event: PointerEvent) => { - if (!titleRef.current?.contains(event.target as Node)) onFinishTitleEditing(); - }; - document.addEventListener("pointerdown", close, true); - return () => document.removeEventListener("pointerdown", close, true); - }, [isTitleEditing, onFinishTitleEditing]); + useEffect(() => { + if (!isTitleEditing) return; + const close = (event: PointerEvent) => { + if (!titleRef.current?.contains(event.target as Node)) onFinishTitleEditing(); + }; + document.addEventListener("pointerdown", close, true); + return () => document.removeEventListener("pointerdown", close, true); + }, [isTitleEditing, onFinishTitleEditing]); - useEffect(() => { - if (!accountOpen) return; - const close = (event: PointerEvent) => { - if (!accountRef.current?.contains(event.target as Node)) setAccountOpen(false); - }; - document.addEventListener("pointerdown", close, true); - return () => document.removeEventListener("pointerdown", close, true); - }, [accountOpen]); + useEffect(() => { + if (!accountOpen) return; + const close = (event: PointerEvent) => { + if (!accountRef.current?.contains(event.target as Node)) setAccountOpen(false); + }; + document.addEventListener("pointerdown", close, true); + return () => document.removeEventListener("pointerdown", close, true); + }, [accountOpen]); - return ( - <> -
-
- , label: "主页", onClick: onHome }, - { key: "projects", icon: , label: "我的画布", onClick: onProjects }, - { type: "divider" }, - { key: "new", icon: , label: "新建画布", onClick: onCreateProject }, - { key: "delete", danger: true, icon: , label: "删除当前画布", onClick: onDeleteProject }, - { type: "divider" }, - { key: "import", icon: , label: "导入图片", onClick: onImportImage }, - { type: "divider" }, - { key: "undo", disabled: !canUndo, icon: , label: , onClick: onUndo }, - { key: "redo", disabled: !canRedo, icon: , label: , onClick: onRedo }, - ], - }} - > - - + return ( + <> +
+
+ , label: "主页", onClick: onHome }, + { key: "projects", icon: , label: "我的画布", onClick: onProjects }, + { type: "divider" }, + { key: "new", icon: , label: "新建画布", onClick: onCreateProject }, + { key: "delete", danger: true, icon: , label: "删除当前画布", onClick: onDeleteProject }, + { type: "divider" }, + { key: "import", icon: , label: "导入图片", onClick: onImportImage }, + { type: "divider" }, + { key: "undo", disabled: !canUndo, icon: , label: , onClick: onUndo }, + { key: "redo", disabled: !canRedo, icon: , label: , onClick: onRedo }, + ], + }} + > + + -
- {isTitleEditing ? ( - onTitleDraftChange(event.target.value)} - onBlur={onFinishTitleEditing} - onKeyDown={(event) => { - if (event.key === "Enter") onFinishTitleEditing(); - if (event.key === "Escape") onCancelTitleEditing(); - }} - className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none" - style={{ color: theme.node.text }} - /> - ) : ( - - )} -
-
+
+ {isTitleEditing ? ( + onTitleDraftChange(event.target.value)} + onBlur={onFinishTitleEditing} + onKeyDown={(event) => { + if (event.key === "Enter") onFinishTitleEditing(); + if (event.key === "Escape") onCancelTitleEditing(); + }} + className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none" + style={{ color: theme.node.text }} + /> + ) : ( + + )} +
+
-
- node.parentElement || document.body} - onOpenShortcuts={() => { setShortcutsOpen(true); setAccountOpen(false); }} - /> - {assistantCollapsed ? ( - <> - - - - ) : null} -
-
- setShortcutsOpen(false)} footer={null} centered> -
- - - - - - - - - - - - - -
-
- - ); +
+ node.parentElement || document.body} + onOpenShortcuts={() => { + setShortcutsOpen(true); + setAccountOpen(false); + }} + /> + {assistantCollapsed ? ( + <> + + + + ) : null} +
+
+ setShortcutsOpen(false)} footer={null} centered> +
+ + + + + + + + + + + + + +
+
+ + ); } function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) { - return ( - - {text} - {shortcut} - - ); + return ( + + {text} + {shortcut} + + ); } function Shortcut({ keys, value }: { keys: string[]; value: string }) { - return ( -
- - {keys.map((key, index) => ( - - {index ? + : null} - {key} - - ))} - - {value} -
- ); + return ( +
+ + {keys.map((key, index) => ( + + {index ? + : null} + + {key} + + + ))} + + {value} +
+ ); } function imageExtension(dataUrl: string) { - return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png"; + return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png"; } function imageMetadata(image: UploadedImage): CanvasNodeMetadata { - return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType }; + return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType }; } function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata { - return { - generationType: type, - model: config.model, - size: config.size, - quality: config.quality, - count, - references: references.map(referenceUrl).filter((url): url is string => Boolean(url)), - }; + return { + generationType: type, + model: config.model, + size: config.size, + quality: config.quality, + count, + references: references.map(referenceUrl).filter((url): url is string => Boolean(url)), + }; } function referenceUrl(image: ReferenceImage) { - return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined); + return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined); } async function resolveMetadataReferences(metadata: CanvasNodeMetadata) { - if (metadata.generationType !== "edit") return []; - if (!metadata.references?.length) return null; - const references = await Promise.all(metadata.references.map(async (url, index) => { - const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url; - return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null; - })); - return references.every(Boolean) ? references as ReferenceImage[] : null; + if (metadata.generationType !== "edit") return []; + if (!metadata.references?.length) return null; + const references = await Promise.all( + metadata.references.map(async (url, index) => { + const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url; + return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null; + }), + ); + return references.every(Boolean) ? (references as ReferenceImage[]) : null; } async function hydrateCanvasImages(nodes: CanvasNodeData[]) { - return Promise.all(nodes.map(async (node) => { - const content = node.metadata?.content; - if (node.type !== CanvasNodeType.Image || !content) return node; - if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } }; - if (!content.startsWith("data:image/")) return node; - return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } }; - })); + return Promise.all( + nodes.map(async (node) => { + const content = node.metadata?.content; + if (node.type !== CanvasNodeType.Image || !content) return node; + if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } }; + if (!content.startsWith("data:image/")) return node; + return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } }; + }), + ); } async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) { - const hydrateItem = async (item: T) => { - if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) }; - if (item.dataUrl?.startsWith("data:image/")) { - const image = await uploadImage(item.dataUrl); - return { ...item, dataUrl: image.url, storageKey: image.storageKey }; - } - return item; - }; - return Promise.all(sessions.map(async (session) => ({ - ...session, - messages: await Promise.all(session.messages.map(async (message) => ({ - ...message, - references: await Promise.all((message.references || []).map(hydrateItem)), - images: await Promise.all((message.images || []).map(hydrateItem)), - }))), - }))); + const hydrateItem = async (item: T) => { + if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) }; + if (item.dataUrl?.startsWith("data:image/")) { + const image = await uploadImage(item.dataUrl); + return { ...item, dataUrl: image.url, storageKey: image.storageKey }; + } + return item; + }; + return Promise.all( + sessions.map(async (session) => ({ + ...session, + messages: await Promise.all( + session.messages.map(async (message) => ({ + ...message, + references: await Promise.all((message.references || []).map(hydrateItem)), + images: await Promise.all((message.images || []).map(hydrateItem)), + })), + ), + })), + ); } function fitImageNodeSize(width: number, height: number, maxWidth = UPLOADED_IMAGE_MAX_SIDE, maxHeight = UPLOADED_IMAGE_MAX_SIDE) { - const safeWidth = Math.max(1, width); - const safeHeight = Math.max(1, height); - const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight); + const safeWidth = Math.max(1, width); + const safeHeight = Math.max(1, height); + const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight); - return { - width: safeWidth * scale, - height: safeHeight * scale, - }; + return { + width: safeWidth * scale, + height: safeHeight * scale, + }; } function getGenerationCount(count: string) { - return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1))); + return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1))); } function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") { - const first = nodes.find((node) => node.id === firstNodeId); - const second = nodes.find((node) => node.id === secondNodeId); - if (!first || !second || first.id === second.id) return null; - if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null; - if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; - if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id }; - if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; - return { fromNodeId: first.id, toNodeId: second.id }; + const first = nodes.find((node) => node.id === firstNodeId); + const second = nodes.find((node) => node.id === secondNodeId); + if (!first || !second || first.id === second.id) return null; + if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null; + if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; + if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id }; + if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; + return { fromNodeId: first.id, toNodeId: second.id }; } function getInputSummary(inputs: NodeGenerationInput[]) { - return { - textCount: inputs.filter((input) => input.type === "text").length, - imageCount: inputs.filter((input) => input.type === "image").length, - }; + return { + textCount: inputs.filter((input) => input.type === "text").length, + imageCount: inputs.filter((input) => input.type === "image").length, + }; } function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig { - const defaultModel = mode === "image" ? config.imageModel : config.textModel; - return { - ...config, - model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model, - quality: node?.metadata?.quality || config.quality || defaultConfig.quality, - size: node?.metadata?.size || config.size || defaultConfig.size, - count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count), - }; + const defaultModel = mode === "image" ? config.imageModel : config.textModel; + return { + ...config, + model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model, + quality: node?.metadata?.quality || config.quality || defaultConfig.quality, + size: node?.metadata?.size || config.size || defaultConfig.size, + count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count), + }; } function resetInterruptedGeneration(nodes: CanvasNodeData[]) { - return nodes.map((node) => node.metadata?.status === "loading" - ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } - : node); + return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node)); } function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { - const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId); - const visited = new Set(); - while (queue.length) { - const id = queue.shift()!; - if (visited.has(id)) continue; - visited.add(id); - const node = nodes.find((item) => item.id === id); - if (node?.type === CanvasNodeType.Config) return node; - connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId)); - } - return null; + const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId); + const visited = new Set(); + while (queue.length) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + const node = nodes.find((item) => item.id === id); + if (node?.type === CanvasNodeType.Config) return node; + connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId)); + } + return null; } function sourceNodeReferenceImages(node: CanvasNodeData | null) { - if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return []; - return [{ - id: node.id, - name: `${node.title || node.id}.png`, - type: node.metadata.mimeType || "image/png", - dataUrl: node.metadata.content, - storageKey: node.metadata.storageKey, - }]; + if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return []; + return [ + { + id: node.id, + name: `${node.title || node.id}.png`, + type: node.metadata.mimeType || "image/png", + dataUrl: node.metadata.content, + storageKey: node.metadata.storageKey, + }, + ]; } function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set) { - const rootId = node.metadata?.batchRootId; - if (!rootId) return false; - const root = nodes.find((item) => item.id === rootId); - if (root && collapsingBatchIds?.has(rootId)) return false; - return Boolean(root && !root.metadata?.imageBatchExpanded); + const rootId = node.metadata?.batchRootId; + if (!rootId) return false; + const root = nodes.find((item) => item.id === rootId); + if (root && collapsingBatchIds?.has(rootId)) return false; + return Boolean(root && !root.metadata?.imageBatchExpanded); } function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) { - const rootId = node.metadata?.batchRootId; - if (!rootId) return false; - const root = nodes.find((item) => item.id === rootId); - return Boolean(root && !root.metadata?.imageBatchExpanded); + const rootId = node.metadata?.batchRootId; + if (!rootId) return false; + const root = nodes.find((item) => item.id === rootId); + return Boolean(root && !root.metadata?.imageBatchExpanded); } function buildAngleLabel(params: CanvasImageAngleParams) { - const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`; - const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`; - return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`; + const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle} 度` : `向左旋转 ${Math.abs(params.horizontalAngle)} 度`; + const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle} 度` : `仰视 ${Math.abs(params.pitchAngle)} 度`; + return `AI 多角度:${horizontal},${pitch},镜头距离 ${params.cameraDistance.toFixed(1)},${params.wideAngle ? "广角" : "标准"}镜头`; } function buildAnglePrompt(params: CanvasImageAngleParams) { - return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`; + return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}。`; } diff --git a/web/src/app/(user)/canvas/[id]/page.tsx b/web/src/app/(user)/canvas/[id]/page.tsx index fd57769..948e1e2 100644 --- a/web/src/app/(user)/canvas/[id]/page.tsx +++ b/web/src/app/(user)/canvas/[id]/page.tsx @@ -1,5 +1,5 @@ import CanvasClientPage from "./canvas-client-page"; export default function CanvasPage() { - return ; + return ; } diff --git a/web/src/app/(user)/canvas/components/asset-picker-modal.tsx b/web/src/app/(user)/canvas/components/asset-picker-modal.tsx index b0d640c..f610b2b 100644 --- a/web/src/app/(user)/canvas/components/asset-picker-modal.tsx +++ b/web/src/app/(user)/canvas/components/asset-picker-modal.tsx @@ -12,254 +12,251 @@ 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 }; +export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string }; type Props = { - open: boolean; - defaultTab?: AssetPickerTab; - onInsert: (payload: InsertAssetPayload) => void; - onClose: () => void; + open: boolean; + defaultTab?: AssetPickerTab; + onInsert: (payload: InsertAssetPayload) => void; + onClose: () => void; }; export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) { - const [activeTab, setActiveTab] = useState(defaultTab); + const [activeTab, setActiveTab] = useState(defaultTab); - useEffect(() => { - if (open) setActiveTab(defaultTab); - }, [open, defaultTab]); + useEffect(() => { + if (open) setActiveTab(defaultTab); + }, [open, defaultTab]); - return ( - - setActiveTab(key as AssetPickerTab)} - items={[ - { key: "my-assets", label: "我的素材", children: }, - { key: "library", label: "素材库", children: }, - ]} - /> - - ); + return ( + + setActiveTab(key as AssetPickerTab)} + items={[ + { key: "my-assets", label: "我的素材", children: }, + { key: "library", label: "素材库", children: }, + ]} + /> + + ); } const PAGE_SIZE = 8; const kindOptions = [ - { label: "全部", value: "all" }, - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, + { label: "全部", value: "all" }, + { label: "文本", value: "text" }, + { label: "图片", value: "image" }, ]; 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(null); + const { message } = App.useApp(); + const [keyword, setKeyword] = useState(""); + const [kindFilter, setKindFilter] = useState(""); + const [page, setPage] = useState(1); + const [inserting, setInserting] = useState(null); - const query = useQuery({ - queryKey: ["asset-picker-library", keyword, kindFilter, page], - queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }), - retry: false, - }); + 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 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); - } - }; + 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 ( -
-
- } - placeholder="搜索素材" - value={keyword} - allowClear - onChange={(e) => { setPage(1); setKeyword(e.target.value); }} - /> -
- {[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => ( - { setPage(1); setKindFilter(opt.value); }} - > - {opt.label} - - ))} + return ( +
+
+ } + placeholder="搜索素材" + value={keyword} + allowClear + onChange={(e) => { + setPage(1); + setKeyword(e.target.value); + }} + /> +
+ {[ + { label: "全部", value: "" }, + { label: "文本", value: "text" }, + { label: "图片", value: "image" }, + ].map((opt) => ( + { + setPage(1); + setKindFilter(opt.value); + }} + > + {opt.label} + + ))} +
+
+ + {query.isLoading ? ( +
+ +
+ ) : items.length ? ( +
+ {items.map((asset) => ( + void handleInsert(asset)} /> + ))} +
+ ) : ( + + )} + + {total > PAGE_SIZE && ( +
+ +
+ )}
-
- - {query.isLoading ? ( -
- ) : items.length ? ( -
- {items.map((asset) => ( - void handleInsert(asset)} - /> - ))} -
- ) : ( - - )} - - {total > PAGE_SIZE && ( -
- -
- )} -
- ); + ); } function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) { - return ( - - ); + return ( + + ); } async function remoteImageToDataUrl(url: string) { - const response = await axios.get(url, { responseType: "blob" }); - const blob = response.data as Blob; - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(new Error("读取图片失败")); - reader.readAsDataURL(blob); - }); + const response = await axios.get(url, { responseType: "blob" }); + const blob = response.data as Blob; + return new Promise((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(""); - const [kindFilter, setKindFilter] = useState("all"); - const [page, setPage] = useState(1); + const assets = useAssetStore((state) => state.assets); + const [keyword, setKeyword] = useState(""); + const [kindFilter, setKindFilter] = useState("all"); + const [page, setPage] = useState(1); - const filtered = useMemo(() => { - const query = keyword.trim().toLowerCase(); - return assets - .filter((a) => a.kind === "text" || a.kind === "image") - .filter((a) => kindFilter === "all" || a.kind === kindFilter) - .filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query)); - }, [assets, keyword, kindFilter]); + const filtered = useMemo(() => { + const query = keyword.trim().toLowerCase(); + return assets + .filter((a) => a.kind === "text" || a.kind === "image") + .filter((a) => kindFilter === "all" || a.kind === kindFilter) + .filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query)); + }, [assets, keyword, kindFilter]); - const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]); + const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]); - useEffect(() => { - const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - setPage((v) => Math.min(v, maxPage)); - }, [filtered.length]); + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + setPage((v) => Math.min(v, maxPage)); + }, [filtered.length]); - const handleInsert = (asset: Asset) => { - if (asset.kind === "text") { - onInsert({ kind: "text", content: asset.data.content, title: asset.title }); - } else { - onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title }); - } - }; + const handleInsert = (asset: Asset) => { + if (asset.kind === "text") { + onInsert({ kind: "text", content: asset.data.content, title: asset.title }); + } else { + onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title }); + } + }; - return ( -
-
- } - placeholder="搜索素材" - value={keyword} - allowClear - onChange={(e) => { setPage(1); setKeyword(e.target.value); }} - /> -
- {kindOptions.map((opt) => ( - { setPage(1); setKindFilter(opt.value); }} - > - {opt.label} - - ))} + return ( +
+
+ } + placeholder="搜索素材" + value={keyword} + allowClear + onChange={(e) => { + setPage(1); + setKeyword(e.target.value); + }} + /> +
+ {kindOptions.map((opt) => ( + { + setPage(1); + setKindFilter(opt.value); + }} + > + {opt.label} + + ))} +
+
+ + {visible.length ? ( +
+ {visible.map((asset) => ( + handleInsert(asset)} /> + ))} +
+ ) : ( + + )} + + {filtered.length > PAGE_SIZE && ( +
+ +
+ )}
-
- - {visible.length ? ( -
- {visible.map((asset) => ( - handleInsert(asset)} /> - ))} -
- ) : ( - - )} - - {filtered.length > PAGE_SIZE && ( -
- -
- )} -
- ); + ); } diff --git a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx index b05a9ad..fcf62e8 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -26,546 +26,643 @@ const PANEL_MOTION_MS = 500; const PANEL_MOTION_SECONDS = PANEL_MOTION_MS / 1000; type CanvasAssistantPanelProps = { - nodes: CanvasNodeData[]; - selectedNodeIds: Set; - sessions: CanvasAssistantSession[]; - activeSessionId: string | null; - onSelectNodeIds: (ids: Set) => void; - onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void; - onInsertImage: (image: CanvasAssistantImage) => void; - onInsertText: (text: string) => void; - onPasteImage: (file: File) => void; - onCollapseStart: () => void; - onCollapse: () => void; + nodes: CanvasNodeData[]; + selectedNodeIds: Set; + sessions: CanvasAssistantSession[]; + activeSessionId: string | null; + onSelectNodeIds: (ids: Set) => void; + onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void; + onInsertImage: (image: CanvasAssistantImage) => void; + onInsertText: (text: string) => void; + onPasteImage: (file: File) => void; + onCollapseStart: () => void; + onCollapse: () => void; }; export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) { - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const config = useConfigStore((state) => state.config); - const effectiveConfig = useEffectiveConfig(); - const cleanupImages = useAssetStore((state) => state.cleanupImages); - const updateConfig = useConfigStore((state) => state.updateConfig); - const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); - const openConfigDialog = useConfigStore((state) => state.openConfigDialog); - const [width, setWidth] = useState(390); - const [view, setView] = useState<"chat" | "history">("chat"); - const [mode, setMode] = useState("image"); - const [prompt, setPrompt] = useState(""); - const [isRunning, setIsRunning] = useState(false); - const [checkedChatIds, setCheckedChatIds] = useState([]); - const [deleteChatIds, setDeleteChatIds] = useState([]); - const [closing, setClosing] = useState(false); - const [resizing, setResizing] = useState(false); - const [removedReferenceIds, setRemovedReferenceIds] = useState>(new Set()); - const [localSessions, setLocalSessions] = useState(() => sessions.length ? sessions : [createSession()]); - const [localActiveSessionId, setLocalActiveSessionId] = useState(activeSessionId); + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const config = useConfigStore((state) => state.config); + const effectiveConfig = useEffectiveConfig(); + const cleanupImages = useAssetStore((state) => state.cleanupImages); + const updateConfig = useConfigStore((state) => state.updateConfig); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const openConfigDialog = useConfigStore((state) => state.openConfigDialog); + const [width, setWidth] = useState(390); + const [view, setView] = useState<"chat" | "history">("chat"); + const [mode, setMode] = useState("image"); + const [prompt, setPrompt] = useState(""); + const [isRunning, setIsRunning] = useState(false); + const [checkedChatIds, setCheckedChatIds] = useState([]); + const [deleteChatIds, setDeleteChatIds] = useState([]); + const [closing, setClosing] = useState(false); + const [resizing, setResizing] = useState(false); + const [removedReferenceIds, setRemovedReferenceIds] = useState>(new Set()); + const [localSessions, setLocalSessions] = useState(() => (sessions.length ? sessions : [createSession()])); + const [localActiveSessionId, setLocalActiveSessionId] = useState(activeSessionId); - useEffect(() => { - if (!sessions.length) return; - setLocalSessions(sessions); - setLocalActiveSessionId(activeSessionId); - }, [activeSessionId, sessions]); + useEffect(() => { + if (!sessions.length) return; + setLocalSessions(sessions); + setLocalActiveSessionId(activeSessionId); + }, [activeSessionId, sessions]); - useEffect(() => { - onSessionsChange(localSessions, localActiveSessionId); - }, [localActiveSessionId, localSessions, onSessionsChange]); + useEffect(() => { + onSessionsChange(localSessions, localActiveSessionId); + }, [localActiveSessionId, localSessions, onSessionsChange]); - const safeSessions = localSessions.length ? localSessions : [createSession()]; - const activeSession = useMemo(() => safeSessions.find((session) => session.id === localActiveSessionId) || safeSessions[0] || null, [localActiveSessionId, safeSessions]); - const historySessions = safeSessions.filter((session) => session.messages.length > 0); - const messages = activeSession?.messages || []; - const hasMessages = messages.length > 0; - const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]); - const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]); - const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]); - const iconButtonStyle = { color: theme.node.muted }; + const safeSessions = localSessions.length ? localSessions : [createSession()]; + const activeSession = useMemo(() => safeSessions.find((session) => session.id === localActiveSessionId) || safeSessions[0] || null, [localActiveSessionId, safeSessions]); + const historySessions = safeSessions.filter((session) => session.messages.length > 0); + const messages = activeSession?.messages || []; + const hasMessages = messages.length > 0; + const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]); + const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]); + const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]); + const iconButtonStyle = { color: theme.node.muted }; - useEffect(() => { - setRemovedReferenceIds(new Set()); - }, [selectedNodeKey]); + useEffect(() => { + setRemovedReferenceIds(new Set()); + }, [selectedNodeKey]); - const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => { - setLocalSessions((prev) => prev.map((session) => session.id === sessionId ? updater(session) : session)); - }; - - const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => { - updateSession(sessionId, (session) => ({ - ...session, - title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话", - messages: [...session.messages, message], - updatedAt: new Date().toISOString(), - })); - }; - - const updateMessage = (sessionId: string, messageId: string, patch: Partial) => { - updateSession(sessionId, (session) => ({ - ...session, - messages: session.messages.map((message) => message.id === messageId ? { ...message, ...patch } : message), - updatedAt: new Date().toISOString(), - })); - }; - - const startChatSession = () => { - if (activeSession && activeSession.messages.length === 0) { - setLocalActiveSessionId(activeSession.id); - return; - } - const session = createSession(); - setLocalSessions((prev) => [session, ...prev]); - setLocalActiveSessionId(session.id); - }; - - const removeSessions = (ids: string[]) => { - const next = safeSessions.filter((session) => !ids.includes(session.id)); - if (!next.length) { - const session = createSession(); - setLocalSessions([session]); - setLocalActiveSessionId(session.id); - } else { - setLocalSessions(next); - setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId); - } - cleanupImages({ sessions: next }); - setCheckedChatIds((prev) => prev.filter((id) => !ids.includes(id))); - }; - - const clearSessions = () => { - const session = createSession(); - setLocalSessions([session]); - setLocalActiveSessionId(session.id); - setCheckedChatIds([]); - cleanupImages({ sessions: [session] }); - }; - - const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => { - const requestConfig = { ...effectiveConfig, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model }; - if (!isAiConfigReady(requestConfig, requestConfig.model)) { - openConfigDialog(true); - return; - } - - const session = activeSession || createSession(); - if (!activeSession) { - setLocalSessions([session]); - setLocalActiveSessionId(session.id); - } - - const refs = savedReferences || selectedReferences; - const userMessage: CanvasAssistantMessage = { id: nanoid(), role: "user", mode: nextMode, text, references: refs }; - const assistantId = nanoid(); - appendMessage(session.id, userMessage); - appendMessage(session.id, { id: assistantId, role: "assistant", mode: nextMode, text: nextMode === "image" ? "正在生成图片" : "正在回答", isLoading: true }); - setPrompt(""); - setIsRunning(true); - - try { - if (nextMode === "image") { - const referenceImages: ReferenceImage[] = await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey }))); - const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text); - const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl))); - updateMessage(session.id, assistantId, { - text: `生成了 ${storedImages.length} 张图片`, - images: storedImages.map((image, index) => ({ id: images[index].id, dataUrl: image.url, storageKey: image.storageKey, prompt: text })), - isLoading: false, - }); - return; - } - - const answer = await requestImageQuestion(requestConfig, await buildChatMessages([...history, userMessage]), (streamed) => { - updateMessage(session.id, assistantId, { text: streamed, isLoading: false }); - }); - updateMessage(session.id, assistantId, { text: answer, isLoading: false }); - } catch (error) { - updateMessage(session.id, assistantId, { text: error instanceof Error ? error.message : "操作失败", isLoading: false }); - } finally { - setIsRunning(false); - } - }; - - const submit = async () => { - const text = prompt.trim(); - if (!text || isRunning) return; - await sendMessage(text, mode, messages); - }; - - const retryMessage = (message: CanvasAssistantMessage) => { - const index = messages.findIndex((item) => item.id === message.id); - const userIndex = messages.slice(0, index).findLastIndex((item) => item.role === "user"); - const user = messages[userIndex]; - if (user) void sendMessage(user.text, user.mode, messages.slice(0, userIndex), user.references); - }; - - const startResize = () => { - const move = (event: MouseEvent) => setWidth(Math.min(760, Math.max(320, window.innerWidth - event.clientX))); - const stop = () => { - setResizing(false); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - document.removeEventListener("mousemove", move); - document.removeEventListener("mouseup", stop); + const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => { + setLocalSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(session) : session))); }; - setResizing(true); - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - document.addEventListener("mousemove", move); - document.addEventListener("mouseup", stop); - }; - const collapse = () => { - setClosing(true); - onCollapseStart(); - window.setTimeout(onCollapse, PANEL_MOTION_MS); - }; + const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => { + updateSession(sessionId, (session) => ({ + ...session, + title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话", + messages: [...session.messages, message], + updatedAt: new Date().toISOString(), + })); + }; - return ( - - -
-
+ const updateMessage = (sessionId: string, messageId: string, patch: Partial) => { + updateSession(sessionId, (session) => ({ + ...session, + messages: session.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), + updatedAt: new Date().toISOString(), + })); + }; -
- {view === "history" ? ( - historySessions.some((session) => session.id === id))} - onToggleChecked={(id, checked) => setCheckedChatIds((prev) => checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id))} - onOpen={(id) => { setLocalActiveSessionId(id); setView("chat"); }} - onDelete={(id) => setDeleteChatIds([id])} - /> - ) : messages.length ? ( - - ) : ( -
-
- Infinite Canvas - -
-
One canvas, infinite ideas
-
- )} -
+ const startChatSession = () => { + if (activeSession && activeSession.messages.length === 0) { + setLocalActiveSessionId(activeSession.id); + return; + } + const session = createSession(); + setLocalSessions((prev) => [session, ...prev]); + setLocalActiveSessionId(session.id); + }; - {view === "chat" ? ( - openConfigDialog(true)} - onRemoveReference={(id) => { - setRemovedReferenceIds((prev) => new Set(prev).add(id)); - if (selectedNodeIds.has(id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== id))); - }} - onPasteImage={onPasteImage} - /> - ) : null} + const removeSessions = (ids: string[]) => { + const next = safeSessions.filter((session) => !ids.includes(session.id)); + if (!next.length) { + const session = createSession(); + setLocalSessions([session]); + setLocalActiveSessionId(session.id); + } else { + setLocalSessions(next); + setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId); + } + cleanupImages({ sessions: next }); + setCheckedChatIds((prev) => prev.filter((id) => !ids.includes(id))); + }; - 0} - centered - onCancel={() => setDeleteChatIds([])} - footer={<> - - - } + const clearSessions = () => { + const session = createSession(); + setLocalSessions([session]); + setLocalActiveSessionId(session.id); + setCheckedChatIds([]); + cleanupImages({ sessions: [session] }); + }; + + const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => { + const requestConfig = { ...effectiveConfig, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model }; + if (!isAiConfigReady(requestConfig, requestConfig.model)) { + openConfigDialog(true); + return; + } + + const session = activeSession || createSession(); + if (!activeSession) { + setLocalSessions([session]); + setLocalActiveSessionId(session.id); + } + + const refs = savedReferences || selectedReferences; + const userMessage: CanvasAssistantMessage = { id: nanoid(), role: "user", mode: nextMode, text, references: refs }; + const assistantId = nanoid(); + appendMessage(session.id, userMessage); + appendMessage(session.id, { id: assistantId, role: "assistant", mode: nextMode, text: nextMode === "image" ? "正在生成图片" : "正在回答", isLoading: true }); + setPrompt(""); + setIsRunning(true); + + try { + if (nextMode === "image") { + const referenceImages: ReferenceImage[] = await Promise.all( + refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })), + ); + const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text); + const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl))); + updateMessage(session.id, assistantId, { + text: `生成了 ${storedImages.length} 张图片`, + images: storedImages.map((image, index) => ({ id: images[index].id, dataUrl: image.url, storageKey: image.storageKey, prompt: text })), + isLoading: false, + }); + return; + } + + const answer = await requestImageQuestion(requestConfig, await buildChatMessages([...history, userMessage]), (streamed) => { + updateMessage(session.id, assistantId, { text: streamed, isLoading: false }); + }); + updateMessage(session.id, assistantId, { text: answer, isLoading: false }); + } catch (error) { + updateMessage(session.id, assistantId, { text: error instanceof Error ? error.message : "操作失败", isLoading: false }); + } finally { + setIsRunning(false); + } + }; + + const submit = async () => { + const text = prompt.trim(); + if (!text || isRunning) return; + await sendMessage(text, mode, messages); + }; + + const retryMessage = (message: CanvasAssistantMessage) => { + const index = messages.findIndex((item) => item.id === message.id); + const userIndex = messages.slice(0, index).findLastIndex((item) => item.role === "user"); + const user = messages[userIndex]; + if (user) void sendMessage(user.text, user.mode, messages.slice(0, userIndex), user.references); + }; + + const startResize = () => { + const move = (event: MouseEvent) => setWidth(Math.min(760, Math.max(320, window.innerWidth - event.clientX))); + const stop = () => { + setResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", stop); + }; + setResizing(true); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", move); + document.addEventListener("mouseup", stop); + }; + + const collapse = () => { + setClosing(true); + onCollapseStart(); + window.setTimeout(onCollapse, PANEL_MOTION_MS); + }; + + return ( + -

将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。

-
- - - ); + + + + + } + > +

将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。

+ +
+ + ); } function AssistantComposer({ - mode, - prompt, - isRunning, - references, - config, - onModeChange, - onPromptChange, - onSubmit, - onConfigChange, - onMissingConfig, - onRemoveReference, - onPasteImage, + mode, + prompt, + isRunning, + references, + config, + onModeChange, + onPromptChange, + onSubmit, + onConfigChange, + onMissingConfig, + onRemoveReference, + onPasteImage, }: { - mode: AssistantMode; - prompt: string; - isRunning: boolean; - references: CanvasAssistantReference[]; - config: AiConfig; - onModeChange: (mode: AssistantMode) => void; - onPromptChange: (prompt: string) => void; - onSubmit: () => void; - onConfigChange: (key: keyof AiConfig, value: string) => void; - onMissingConfig: () => void; - onRemoveReference: (id: string) => void; - onPasteImage: (file: File) => void; + mode: AssistantMode; + prompt: string; + isRunning: boolean; + references: CanvasAssistantReference[]; + config: AiConfig; + onModeChange: (mode: AssistantMode) => void; + onPromptChange: (prompt: string) => void; + onSubmit: () => void; + onConfigChange: (key: keyof AiConfig, value: string) => void; + onMissingConfig: () => void; + onRemoveReference: (id: string) => void; + onPasteImage: (file: File) => void; }) { - const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const theme = canvasThemes[useThemeStore((state) => state.theme)]; - return ( -
event.stopPropagation()}> - {references.length ? ( -
- {references.map((item) => onRemoveReference(item.id)} />)} + return ( +
event.stopPropagation()}> + {references.length ? ( +
+ {references.map((item) => ( + onRemoveReference(item.id)} /> + ))} +
+ ) : null} +
+