feat: 完善画布管理与快捷键体验
修改历史: - 为画布编辑器补齐节点复制粘贴、全选、追加选择、缩放、删除、生成等快捷键,并新增快捷键说明弹窗 - 支持粘贴剪贴板文本/图片为画布节点、拖入图片上传,以及多选节点复制时保留内部连接 - 在 /canvas 画布管理页新增编辑和删除操作,可编辑标题与说明,并支持删除当前画布 - 为画布项目新增 description 字段,补充数据库兼容迁移、列表展示、API 保存以及导入导出保留说明 - 优化画布节点、提示词引用、配置节点尺寸、生成任务状态与相关服务逻辑
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
|
||||||
|
<option name="type" value="no" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="imagegen" uuid="5e9db309-315c-4765-a986-0c7ee9e518a5">
|
||||||
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/imagegen.sqlite</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/imagegen-tools.iml" filepath="$PROJECT_DIR$/.idea/imagegen-tools.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 画布多人协作保存扩展需求
|
||||||
|
|
||||||
|
> 扩展需求:当前暂不实现。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前画布编辑器采用单人编辑模型。前端在画布数据变化后通过 debounce 触发保存,请求 `PATCH /api/canvas/projects/[id]`,服务端将画布数据作为整份 JSON 写入 `canvas_projects.data`。
|
||||||
|
|
||||||
|
这个机制适合单人场景:用户新增节点、移动节点、编辑 prompt、删除连接等操作后,只需要可靠地把最新画布状态持久化即可。当前保存状态可以继续围绕 HTTP 请求结果展示,例如“保存中”“已同步”“保存失败”。
|
||||||
|
|
||||||
|
## 判断结论
|
||||||
|
|
||||||
|
单人保存不需要 WebSocket。现有 HTTP PATCH 自动保存已经能覆盖主要需求,后续更应该优先完善保存状态、错误提示和失败重试。
|
||||||
|
|
||||||
|
多人实时协作需要实时通道。WebSocket 适合承载协作者加入、在线状态、光标/选区、画布变更广播和冲突提示,但它只解决消息传输,不自动解决权限、身份、冲突合并和最终落库。
|
||||||
|
|
||||||
|
由于项目未来部署形态暂不确定,协作能力应通过 transport adapter 隔离。这样本机或内网 Node 部署可以使用自建 WebSocket server,Serverless 部署可以替换为托管实时服务,而上层画布协作协议保持一致。
|
||||||
|
|
||||||
|
## 第一版目标
|
||||||
|
|
||||||
|
- 提供轻量实时协作,而不是严格无冲突协同编辑。
|
||||||
|
- 广播节点和连接的增删改结果,让其他已打开同一画布的客户端自动同步。
|
||||||
|
- 广播在线状态、光标位置和选中节点,帮助用户知道其他协作者正在查看或编辑的位置。
|
||||||
|
- 通过版本号检测基础冲突,避免旧客户端静默覆盖新状态。
|
||||||
|
- 保留现有 HTTP PATCH 作为最终落库和实时通道不可用时的回退路径。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 当前不实现严格 CRDT 或操作日志合并。
|
||||||
|
- 当前不引入用户登录、权限模型或项目成员体系。
|
||||||
|
- 当前不改造图片生成任务的 SSE 监听机制。
|
||||||
|
- 当前不修改数据库 schema、前端画布组件或运行时配置。
|
||||||
|
|
||||||
|
## 未来方案草案
|
||||||
|
|
||||||
|
后续实现时,可以为画布项目引入版本字段,例如 `version: number`。客户端提交协作变更时携带 `baseVersion`,服务端仅接受基于当前版本的变更。服务端接受变更后递增版本、写入数据库,并向同一画布房间内的其他客户端广播新的画布状态。
|
||||||
|
|
||||||
|
建议的协作事件包括:
|
||||||
|
|
||||||
|
- `join_canvas`:客户端进入画布,携带 `projectId`、`clientId` 和显示名称。
|
||||||
|
- `presence`:广播在线协作者、光标位置和选中状态。
|
||||||
|
- `canvas_patch`:客户端提交画布变更,携带 `baseVersion` 和变更后的画布数据。
|
||||||
|
- `canvas_state`:服务端确认并广播最新 `version` 和画布数据。
|
||||||
|
- `conflict`:客户端基于旧版本提交时返回,提示客户端重新同步。
|
||||||
|
|
||||||
|
第一版冲突策略可以采用版本号加后写覆盖保护:服务端拒绝旧版本提交,客户端收到 `conflict` 后重新拉取或应用服务端状态。暂不尝试自动合并同一节点字段的并发修改。
|
||||||
|
|
||||||
|
## 测试场景
|
||||||
|
|
||||||
|
- 单人保存:新增节点、移动节点、编辑 prompt、删除连接后,保存状态从“保存中”回到“已同步”。
|
||||||
|
- 双窗口同步:两个窗口打开同一画布,窗口 A 新增节点后,窗口 B 无需刷新即可看到更新。
|
||||||
|
- 冲突处理:两个窗口基于同一旧版本同时修改,后提交方收到冲突提示,不能静默覆盖服务端状态。
|
||||||
|
- 断线回退:实时通道断开后,客户端显示离线或错误状态,并可通过 HTTP PATCH 回退保存。
|
||||||
|
- 生成任务回归:图片生成期间的 loading/result 节点仍能正确保存和同步,现有 SSE 任务监听保持不变。
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -22,6 +22,7 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
item: createCanvasProjectExport({
|
item: createCanvasProjectExport({
|
||||||
title: project.title,
|
title: project.title,
|
||||||
|
description: project.description,
|
||||||
data: project.data,
|
data: project.data,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ export async function PATCH(
|
|||||||
const payload = await request.json().catch(() => ({}));
|
const payload = await request.json().catch(() => ({}));
|
||||||
const patch: {
|
const patch: {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
data?: CanvasProjectData;
|
data?: CanvasProjectData;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (typeof payload.title === "string") {
|
if (typeof payload.title === "string") {
|
||||||
patch.title = payload.title;
|
patch.title = payload.title;
|
||||||
}
|
}
|
||||||
|
if (typeof payload.description === "string") {
|
||||||
|
patch.description = payload.description;
|
||||||
|
}
|
||||||
if ("data" in payload) {
|
if ("data" in payload) {
|
||||||
const data = normalizeCanvasProjectData(payload.data);
|
const data = normalizeCanvasProjectData(payload.data);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -55,6 +59,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
const project = updateCanvasProject(id, {
|
const project = updateCanvasProject(id, {
|
||||||
title: patch.title,
|
title: patch.title,
|
||||||
|
description: patch.description,
|
||||||
data: patch.data,
|
data: patch.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ export async function POST(request: Request) {
|
|||||||
typeof payload.title === "string" && payload.title.trim()
|
typeof payload.title === "string" && payload.title.trim()
|
||||||
? payload.title
|
? payload.title
|
||||||
: "未命名画布";
|
: "未命名画布";
|
||||||
|
const description =
|
||||||
|
typeof payload.description === "string" ? payload.description : "";
|
||||||
const data = normalizeCanvasProjectData(payload.data) ?? defaultCanvasProjectData;
|
const data = normalizeCanvasProjectData(payload.data) ?? defaultCanvasProjectData;
|
||||||
const project = createCanvasProject({ title, data });
|
const project = createCanvasProject({ title, description, data });
|
||||||
|
|
||||||
return NextResponse.json({ item: project }, { status: 201 });
|
return NextResponse.json({ item: project }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { streamImageJobEvents } from "@/lib/server/services/image-job-service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
return streamImageJobEvents(request);
|
||||||
|
}
|
||||||
@@ -30,3 +30,9 @@ textarea,
|
|||||||
select {
|
select {
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes canvas-connection-flow {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -26;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
import type { CanvasConnection, CanvasNode, CanvasPosition } from "@/lib/canvas/types";
|
import type {
|
||||||
|
CanvasConnection,
|
||||||
|
CanvasImageNodeMetadata,
|
||||||
|
CanvasNode,
|
||||||
|
CanvasPosition,
|
||||||
|
} from "@/lib/canvas/types";
|
||||||
import { getActiveConnectionPath, getConnectionPath } from "@/lib/canvas/geometry";
|
import { getActiveConnectionPath, getConnectionPath } from "@/lib/canvas/geometry";
|
||||||
|
|
||||||
type CanvasConnectionsProps = {
|
type CanvasConnectionsProps = {
|
||||||
@@ -41,6 +46,7 @@ export function CanvasConnections({
|
|||||||
<ConnectionPath
|
<ConnectionPath
|
||||||
key={connection.id}
|
key={connection.id}
|
||||||
active={connection.id === selectedConnectionId}
|
active={connection.id === selectedConnectionId}
|
||||||
|
animated={isConnectionAnimating(to)}
|
||||||
connection={connection}
|
connection={connection}
|
||||||
from={from}
|
from={from}
|
||||||
to={to}
|
to={to}
|
||||||
@@ -72,16 +78,18 @@ function ConnectionPath({
|
|||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
active,
|
active,
|
||||||
|
animated,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
connection: CanvasConnection;
|
connection: CanvasConnection;
|
||||||
from: CanvasNode;
|
from: CanvasNode;
|
||||||
to: CanvasNode;
|
to: CanvasNode;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
animated: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
const path = getConnectionPath(from, to);
|
const path = getConnectionPath(from, to);
|
||||||
const stroke = active ? "#18181b" : "#a1a1aa";
|
const stroke = active || animated ? "#18181b" : "#a1a1aa";
|
||||||
|
|
||||||
function handleContextMenu(event: ReactMouseEvent<SVGPathElement>) {
|
function handleContextMenu(event: ReactMouseEvent<SVGPathElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -109,13 +117,35 @@ function ConnectionPath({
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke={stroke}
|
stroke={stroke}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeOpacity={active ? 1 : 0.72}
|
strokeOpacity={active || animated ? 1 : 0.72}
|
||||||
strokeWidth={active ? 3 : 2}
|
strokeWidth={active || animated ? 3 : 2}
|
||||||
style={{
|
style={{
|
||||||
filter: active ? "drop-shadow(0 0 8px rgba(17,24,39,.25))" : undefined,
|
filter:
|
||||||
|
active || animated
|
||||||
|
? "drop-shadow(0 0 8px rgba(17,24,39,.25))"
|
||||||
|
: undefined,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{animated ? (
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="#2e77ff"
|
||||||
|
strokeDasharray="12 14"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="3"
|
||||||
|
style={{
|
||||||
|
animation: "canvas-connection-flow 900ms linear infinite",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConnectionAnimating(to: CanvasNode) {
|
||||||
|
if (to.type !== "image") return false;
|
||||||
|
return (to.metadata as CanvasImageNodeMetadata).status === "loading";
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type FormEvent,
|
type FormEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react";
|
import { FileText, ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { splitPromptSegments } from "@/components/canvas/prompt-mention-preview";
|
import { splitPromptSegments } from "@/components/canvas/prompt-mention-preview";
|
||||||
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
|
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -45,7 +44,6 @@ type CanvasNodeInspectorProps = {
|
|||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
isHistoryLoading: boolean;
|
isHistoryLoading: boolean;
|
||||||
job: ImageJobPayload | null;
|
job: ImageJobPayload | null;
|
||||||
error: string | null;
|
|
||||||
showLibrary: boolean;
|
showLibrary: boolean;
|
||||||
promptMentionSources: PromptMentionSource[];
|
promptMentionSources: PromptMentionSource[];
|
||||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||||
@@ -62,7 +60,6 @@ export function CanvasNodeInspector({
|
|||||||
history,
|
history,
|
||||||
isHistoryLoading,
|
isHistoryLoading,
|
||||||
job,
|
job,
|
||||||
error,
|
|
||||||
showLibrary,
|
showLibrary,
|
||||||
promptMentionSources,
|
promptMentionSources,
|
||||||
onPatchNode,
|
onPatchNode,
|
||||||
@@ -80,7 +77,7 @@ export function CanvasNodeInspector({
|
|||||||
.includes(historyFilter.toLowerCase()),
|
.includes(historyFilter.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!node && !showLibrary && !job?.progressMessage && !error) {
|
if (!node && !showLibrary && !job?.progressMessage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,23 +86,6 @@ export function CanvasNodeInspector({
|
|||||||
className="pointer-events-none absolute inset-0 z-[85]"
|
className="pointer-events-none absolute inset-0 z-[85]"
|
||||||
data-canvas-ui
|
data-canvas-ui
|
||||||
>
|
>
|
||||||
<div className="absolute bottom-24 left-5 flex max-h-[calc(100vh-170px)] w-[392px] flex-col gap-3">
|
|
||||||
{job?.progressMessage || error ? (
|
|
||||||
<FloatingCard className="pointer-events-auto">
|
|
||||||
{job?.progressMessage ? (
|
|
||||||
<div className="rounded-2xl bg-zinc-100 px-4 py-3 text-sm text-zinc-700">
|
|
||||||
{job.progressMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</FloatingCard>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute right-5 top-24 flex w-[380px] max-h-[calc(100vh-150px)] flex-col gap-3">
|
<div className="absolute right-5 top-24 flex w-[380px] max-h-[calc(100vh-150px)] flex-col gap-3">
|
||||||
{node ? (
|
{node ? (
|
||||||
<FloatingCard className="pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200">
|
<FloatingCard className="pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200">
|
||||||
@@ -277,10 +257,15 @@ function SelectedNodeForm({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{node.type === "config" ? (
|
{node.type === "config" ? (
|
||||||
<ConfigFields node={node} onPatchNode={onPatchNode} />
|
<ConfigFields
|
||||||
|
node={node}
|
||||||
|
onGenerateNode={onGenerateNode}
|
||||||
|
onPatchNode={onPatchNode}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{node.type === "image" ? (
|
{node.type === "image" ? (
|
||||||
<ImageFields
|
<ImageFields
|
||||||
|
mentionSources={promptMentionSources}
|
||||||
node={node}
|
node={node}
|
||||||
onPatchNode={onPatchNode}
|
onPatchNode={onPatchNode}
|
||||||
onReplaceNodeImage={onReplaceNodeImage}
|
onReplaceNodeImage={onReplaceNodeImage}
|
||||||
@@ -301,8 +286,40 @@ function PromptFields({
|
|||||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||||
}) {
|
}) {
|
||||||
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
||||||
|
function patchPrompt(nextPrompt: string) {
|
||||||
|
onPatchNode(node.id, {
|
||||||
|
metadata: { ...metadata, prompt: nextPrompt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MentionPromptEditor
|
||||||
|
emptyState="当前提示词节点还没有上游图片素材或文本,连接节点后即可使用 `@` 引用。"
|
||||||
|
mentionHelp="只允许引用已连接到当前提示词节点的图片素材或文本。"
|
||||||
|
mentionSources={mentionSources}
|
||||||
|
placeholder={mentionSources.length ? "输入提示词,键入 @ 引用已连接素材" : "输入提示词"}
|
||||||
|
value={metadata.prompt || ""}
|
||||||
|
onChange={patchPrompt}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentionPromptEditor({
|
||||||
|
emptyState,
|
||||||
|
mentionHelp,
|
||||||
|
mentionSources,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
emptyState: string;
|
||||||
|
mentionHelp: string;
|
||||||
|
mentionSources: PromptMentionSource[];
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const lastPromptRef = useRef(metadata.prompt || "");
|
|
||||||
const [mentionState, setMentionState] = useState<{
|
const [mentionState, setMentionState] = useState<{
|
||||||
query: string;
|
query: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -316,66 +333,46 @@ function PromptFields({
|
|||||||
: mentionSources;
|
: mentionSources;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPrompt = metadata.prompt || "";
|
renderPromptEditor(editorRef.current, value, mentionSources);
|
||||||
if (lastPromptRef.current === currentPrompt) return;
|
}, [mentionSources, value]);
|
||||||
lastPromptRef.current = currentPrompt;
|
|
||||||
renderPromptEditor(editorRef.current, currentPrompt, mentionSources);
|
|
||||||
}, [mentionSources, metadata.prompt]);
|
|
||||||
|
|
||||||
function updateMentionState(value: string) {
|
function updateMentionState(element: HTMLDivElement) {
|
||||||
const atIndex = value.lastIndexOf("@");
|
const activeQuery = getActiveMentionQuery(element);
|
||||||
if (atIndex === -1) {
|
if (!activeQuery) {
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (atIndex > 0 && /\S/.test(value[atIndex - 1] || "")) {
|
setMentionState({ query: activeQuery.query });
|
||||||
setMentionState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const query = value.slice(atIndex + 1);
|
|
||||||
if (query.includes(" ") || query.includes("\n")) {
|
|
||||||
setMentionState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMentionState({
|
|
||||||
query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchPrompt(nextPrompt: string) {
|
|
||||||
lastPromptRef.current = nextPrompt;
|
|
||||||
onPatchNode(node.id, {
|
|
||||||
metadata: { ...metadata, prompt: nextPrompt },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditorInput(event: FormEvent<HTMLDivElement>) {
|
function handleEditorInput(event: FormEvent<HTMLDivElement>) {
|
||||||
|
normalizePromptEditorAliases(event.currentTarget, mentionSources);
|
||||||
const nextPrompt = serializePromptEditor(event.currentTarget);
|
const nextPrompt = serializePromptEditor(event.currentTarget);
|
||||||
event.currentTarget.dataset.renderedPrompt = nextPrompt;
|
event.currentTarget.dataset.renderedPrompt = nextPrompt;
|
||||||
patchPrompt(nextPrompt);
|
onChange(nextPrompt);
|
||||||
updateMentionState(nextPrompt);
|
updateMentionState(event.currentTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertMention(source: PromptMentionSource) {
|
function insertMention(source: PromptMentionSource) {
|
||||||
const element = editorRef.current;
|
const element = editorRef.current;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
replaceActiveMentionQuery(element, mentionState?.query ?? "", source);
|
replaceActiveMentionQuery(element, source);
|
||||||
const nextPrompt = serializePromptEditor(element);
|
const nextPrompt = serializePromptEditor(element);
|
||||||
element.dataset.renderedPrompt = nextPrompt;
|
element.dataset.renderedPrompt = nextPrompt;
|
||||||
patchPrompt(nextPrompt);
|
onChange(nextPrompt);
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Field label="提示词">
|
<Field label="">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
editorRef.current = element;
|
editorRef.current = element;
|
||||||
renderPromptEditor(element, metadata.prompt || "", mentionSources);
|
renderPromptEditor(element, value, mentionSources);
|
||||||
}}
|
}}
|
||||||
className="thin-scrollbar min-h-36 w-full overflow-auto whitespace-pre-wrap break-words rounded-2xl border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 text-zinc-950 shadow-xs outline-none transition-colors focus:border-zinc-400"
|
className="thin-scrollbar min-h-36 w-full overflow-auto whitespace-pre-wrap break-words rounded-2xl border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 text-zinc-950 shadow-xs outline-none transition-colors focus:border-zinc-400"
|
||||||
contentEditable
|
contentEditable
|
||||||
@@ -384,7 +381,7 @@ function PromptFields({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onInput={handleEditorInput}
|
onInput={handleEditorInput}
|
||||||
onKeyDown={(event) => handleEditorKeyDown(event, handleEditorInput)}
|
onKeyDown={(event) => handleEditorKeyDown(event, handleEditorInput)}
|
||||||
onKeyUp={(event) => updateMentionState(serializePromptEditor(event.currentTarget))}
|
onKeyUp={(event) => updateMentionState(event.currentTarget)}
|
||||||
onPaste={pastePlainText}
|
onPaste={pastePlainText}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!editorRef.current?.textContent?.trim()) {
|
if (!editorRef.current?.textContent?.trim()) {
|
||||||
@@ -395,18 +392,16 @@ function PromptFields({
|
|||||||
window.setTimeout(() => setMentionState(null), 120);
|
window.setTimeout(() => setMentionState(null), 120);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!(metadata.prompt || "").trim() ? (
|
{!value.trim() ? (
|
||||||
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-6 text-zinc-400">
|
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-6 text-zinc-400">
|
||||||
{mentionSources.length
|
{placeholder}
|
||||||
? "输入提示词,键入 @ 引用已连接素材"
|
|
||||||
: "输入提示词"}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{mentionState && filteredMentionSources.length > 0 ? (
|
{mentionState && filteredMentionSources.length > 0 ? (
|
||||||
<div className="rounded-2xl border border-zinc-200 bg-white p-2 shadow-sm">
|
<div className="rounded-2xl border border-zinc-200 bg-white p-2 shadow-sm">
|
||||||
<div className="mb-2 px-2 text-[11px] uppercase tracking-[0.18em] text-zinc-400">
|
<div className="mb-2 px-2 text-[11px] uppercase tracking-[0.18em] text-zinc-400">
|
||||||
已连接素材
|
已连接素材/文本
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{filteredMentionSources.map((source) => (
|
{filteredMentionSources.map((source) => (
|
||||||
@@ -426,6 +421,8 @@ function PromptFields({
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
src={source.imageUrl}
|
src={source.imageUrl}
|
||||||
/>
|
/>
|
||||||
|
) : source.kind === "prompt" ? (
|
||||||
|
<FileText className="size-4 text-zinc-500" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="size-4 text-zinc-400" />
|
<ImageIcon className="size-4 text-zinc-400" />
|
||||||
)}
|
)}
|
||||||
@@ -450,7 +447,7 @@ function PromptFields({
|
|||||||
</Field>
|
</Field>
|
||||||
{mentionSources.length ? (
|
{mentionSources.length ? (
|
||||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3">
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3">
|
||||||
<div className="text-xs font-medium text-zinc-800">可引用的上游素材</div>
|
<div className="text-xs font-medium text-zinc-800">可引用的上游素材/文本</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{mentionSources.map((source) => (
|
{mentionSources.map((source) => (
|
||||||
<button
|
<button
|
||||||
@@ -466,6 +463,8 @@ function PromptFields({
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
src={source.imageUrl}
|
src={source.imageUrl}
|
||||||
/>
|
/>
|
||||||
|
) : source.kind === "prompt" ? (
|
||||||
|
<FileText className="size-3 text-zinc-500" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="size-3 text-zinc-400" />
|
<ImageIcon className="size-3 text-zinc-400" />
|
||||||
)}
|
)}
|
||||||
@@ -475,12 +474,12 @@ function PromptFields({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs leading-5 text-zinc-500">
|
<div className="mt-2 text-xs leading-5 text-zinc-500">
|
||||||
只允许引用已连接到当前提示词节点的图片素材。
|
{mentionHelp}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50 p-3 text-xs leading-5 text-zinc-500">
|
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50 p-3 text-xs leading-5 text-zinc-500">
|
||||||
当前提示词节点还没有上游图片素材,连接图片节点后即可使用 `[图1](canvas-image://...)` 引用图片。
|
{emptyState}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -528,6 +527,9 @@ function createMentionCard(source: PromptMentionSource) {
|
|||||||
image.className = "h-full w-full object-cover";
|
image.className = "h-full w-full object-cover";
|
||||||
image.src = source.imageUrl;
|
image.src = source.imageUrl;
|
||||||
imageWrap.append(image);
|
imageWrap.append(image);
|
||||||
|
} else if (source.kind === "prompt") {
|
||||||
|
imageWrap.textContent = "文";
|
||||||
|
imageWrap.className += " text-[10px] font-semibold text-zinc-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy = document.createElement("span");
|
const copy = document.createElement("span");
|
||||||
@@ -543,20 +545,24 @@ function createMentionCard(source: PromptMentionSource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializePromptEditor(element: HTMLDivElement) {
|
function serializePromptEditor(element: HTMLDivElement) {
|
||||||
|
return serializePromptNode(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializePromptNode(node: Node): string {
|
||||||
let result = "";
|
let result = "";
|
||||||
|
|
||||||
for (const node of element.childNodes) {
|
for (const child of node.childNodes) {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
result += node.textContent || "";
|
result += child.textContent || "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node instanceof HTMLElement) {
|
if (child instanceof HTMLElement) {
|
||||||
if (node.dataset.mentionToken) {
|
if (child.dataset.mentionToken) {
|
||||||
result += node.dataset.mentionToken;
|
result += child.dataset.mentionToken;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
result += node.innerText || node.textContent || "";
|
result += serializePromptNode(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,32 +571,124 @@ function serializePromptEditor(element: HTMLDivElement) {
|
|||||||
|
|
||||||
function replaceActiveMentionQuery(
|
function replaceActiveMentionQuery(
|
||||||
element: HTMLDivElement,
|
element: HTMLDivElement,
|
||||||
query: string,
|
|
||||||
source: PromptMentionSource,
|
source: PromptMentionSource,
|
||||||
) {
|
) {
|
||||||
element.focus();
|
element.focus();
|
||||||
|
const activeQuery = getActiveMentionQuery(element);
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
const textNode = selection?.anchorNode;
|
const textNode = activeQuery?.textNode;
|
||||||
if (!selection || !textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
if (!selection || !activeQuery || !textNode) {
|
||||||
element.append(createMentionCard(source), document.createTextNode(" "));
|
element.append(createMentionCard(source), document.createTextNode(" "));
|
||||||
placeCaretAtEnd(element);
|
placeCaretAtEnd(element);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = textNode.textContent || "";
|
const text = textNode.textContent || "";
|
||||||
const cursor = selection.anchorOffset;
|
const before = text.slice(0, activeQuery.start);
|
||||||
const replaceStart = Math.max(0, cursor - query.length - 1);
|
const after = text.slice(activeQuery.end);
|
||||||
const before = text.slice(0, replaceStart);
|
|
||||||
const after = text.slice(cursor);
|
|
||||||
const parent = textNode.parentNode;
|
const parent = textNode.parentNode;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
if (before) fragment.append(document.createTextNode(before));
|
if (before) fragment.append(document.createTextNode(before));
|
||||||
fragment.append(createMentionCard(source), document.createTextNode(" "));
|
const card = createMentionCard(source);
|
||||||
|
const spacer = document.createTextNode(" ");
|
||||||
|
fragment.append(card, spacer);
|
||||||
if (after) fragment.append(document.createTextNode(after));
|
if (after) fragment.append(document.createTextNode(after));
|
||||||
parent.replaceChild(fragment, textNode);
|
parent.replaceChild(fragment, textNode);
|
||||||
placeCaretAtEnd(element);
|
placeCaretAfter(spacer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveMentionQuery(element: HTMLDivElement) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!element.contains(selection.anchorNode)) return null;
|
||||||
|
|
||||||
|
let textNode =
|
||||||
|
selection.anchorNode?.nodeType === Node.TEXT_NODE
|
||||||
|
? selection.anchorNode
|
||||||
|
: null;
|
||||||
|
let offset = selection.anchorOffset;
|
||||||
|
|
||||||
|
if (!textNode && selection.anchorNode instanceof HTMLElement) {
|
||||||
|
const child = selection.anchorNode.childNodes.item(
|
||||||
|
Math.max(0, selection.anchorOffset - 1),
|
||||||
|
);
|
||||||
|
if (child?.nodeType === Node.TEXT_NODE) {
|
||||||
|
textNode = child;
|
||||||
|
offset = child.textContent?.length ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return null;
|
||||||
|
|
||||||
|
const text = textNode.textContent || "";
|
||||||
|
const beforeCursor = text.slice(0, offset);
|
||||||
|
const match = /(^|\s)@([^\s@]*)$/.exec(beforeCursor);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
textNode,
|
||||||
|
query: match[2],
|
||||||
|
start: beforeCursor.length - match[2].length - 1,
|
||||||
|
end: offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePromptEditorAliases(
|
||||||
|
element: HTMLDivElement,
|
||||||
|
mentionSources: PromptMentionSource[],
|
||||||
|
) {
|
||||||
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||||
|
const textNodes: Text[] = [];
|
||||||
|
let current = walker.nextNode();
|
||||||
|
while (current) {
|
||||||
|
if (!current.parentElement?.closest("[data-mention-token]")) {
|
||||||
|
textNodes.push(current as Text);
|
||||||
|
}
|
||||||
|
current = walker.nextNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const textNode of textNodes) {
|
||||||
|
replaceMentionAliasesInTextNode(textNode, mentionSources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMentionAliasesInTextNode(
|
||||||
|
textNode: Text,
|
||||||
|
mentionSources: PromptMentionSource[],
|
||||||
|
) {
|
||||||
|
const text = textNode.textContent || "";
|
||||||
|
const sortedSources = [...mentionSources].sort(
|
||||||
|
(left, right) => right.alias.length - left.alias.length,
|
||||||
|
);
|
||||||
|
let cursor = 0;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
while (cursor < text.length) {
|
||||||
|
const matchedSource = sortedSources.find((source) =>
|
||||||
|
text.startsWith(source.alias, cursor),
|
||||||
|
);
|
||||||
|
if (!matchedSource) {
|
||||||
|
const nextAliasIndex = sortedSources.reduce((nextIndex, source) => {
|
||||||
|
const index = text.indexOf(source.alias, cursor + 1);
|
||||||
|
return index === -1 ? nextIndex : Math.min(nextIndex, index);
|
||||||
|
}, text.length);
|
||||||
|
fragment.append(document.createTextNode(text.slice(cursor, nextAliasIndex)));
|
||||||
|
cursor = nextAliasIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment.append(createMentionCard(matchedSource));
|
||||||
|
cursor += matchedSource.alias.length;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
textNode.replaceWith(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditorKeyDown(
|
function handleEditorKeyDown(
|
||||||
@@ -632,12 +730,22 @@ function placeCaretAtEnd(element: HTMLElement | null) {
|
|||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function placeCaretAfter(node: Node) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStartAfter(node);
|
||||||
|
range.setEndAfter(node);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
function ConfigFields({
|
function ConfigFields({
|
||||||
node,
|
node,
|
||||||
|
onGenerateNode,
|
||||||
onPatchNode,
|
onPatchNode,
|
||||||
}: {
|
}: {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
|
onGenerateNode: (id: string) => void;
|
||||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||||
}) {
|
}) {
|
||||||
const metadata = node.metadata as CanvasConfigNodeMetadata;
|
const metadata = node.metadata as CanvasConfigNodeMetadata;
|
||||||
@@ -711,6 +819,7 @@ function ConfigFields({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-12 w-full items-center justify-center rounded-2xl bg-zinc-950 text-sm font-medium text-white transition hover:bg-zinc-800"
|
className="flex h-12 w-full items-center justify-center rounded-2xl bg-zinc-950 text-sm font-medium text-white transition hover:bg-zinc-800"
|
||||||
|
onClick={() => onGenerateNode(node.id)}
|
||||||
>
|
>
|
||||||
开始生成
|
开始生成
|
||||||
</button>
|
</button>
|
||||||
@@ -719,10 +828,12 @@ function ConfigFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImageFields({
|
function ImageFields({
|
||||||
|
mentionSources,
|
||||||
node,
|
node,
|
||||||
onPatchNode,
|
onPatchNode,
|
||||||
onReplaceNodeImage,
|
onReplaceNodeImage,
|
||||||
}: {
|
}: {
|
||||||
|
mentionSources: PromptMentionSource[];
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
|
||||||
onReplaceNodeImage: (nodeId: string, file: File) => void;
|
onReplaceNodeImage: (nodeId: string, file: File) => void;
|
||||||
@@ -734,11 +845,17 @@ function ImageFields({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Field label="提示词">
|
<Field label="提示词">
|
||||||
<Textarea
|
<MentionPromptEditor
|
||||||
className="min-h-28 rounded-2xl border-zinc-200 bg-white font-mono"
|
emptyState="当前图像节点还没有上游图片素材或文本,连接节点后即可使用 `@` 引用。"
|
||||||
placeholder="描述当前图像节点想要生成或编辑的内容"
|
mentionHelp="只允许引用已连接到当前图像节点的图片素材或文本。"
|
||||||
|
mentionSources={mentionSources}
|
||||||
|
placeholder={
|
||||||
|
mentionSources.length
|
||||||
|
? "描述生成或编辑内容,键入 @ 引用已连接节点"
|
||||||
|
: "描述当前图像节点想要生成或编辑的内容"
|
||||||
|
}
|
||||||
value={metadata.prompt || ""}
|
value={metadata.prompt || ""}
|
||||||
onChange={(event) => patch({ prompt: event.target.value })}
|
onChange={(prompt) => patch({ prompt })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="图片文件">
|
<Field label="图片文件">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Minus,
|
Minus,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PromptMentionPreview } from "@/components/canvas/prompt-mention-preview";
|
import { PromptMentionPreview } from "@/components/canvas/prompt-mention-preview";
|
||||||
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
|
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
|
||||||
|
import { getConfigNodeSize } from "@/lib/canvas/node-factory";
|
||||||
import type {
|
import type {
|
||||||
CanvasConfigNodeMetadata,
|
CanvasConfigNodeMetadata,
|
||||||
CanvasImageNodeMetadata,
|
CanvasImageNodeMetadata,
|
||||||
@@ -43,10 +45,11 @@ type CanvasNodeProps = {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
related: boolean;
|
related: boolean;
|
||||||
imageReferenceLabel?: string;
|
imageReferenceLabel?: string;
|
||||||
|
promptReferenceLabel?: string;
|
||||||
activeConnecting: boolean;
|
activeConnecting: boolean;
|
||||||
connectionTarget: boolean;
|
connectionTarget: boolean;
|
||||||
promptMentionSources?: PromptMentionSource[];
|
promptMentionSources?: PromptMentionSource[];
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string, additive?: boolean) => void;
|
||||||
onDragStart: (event: ReactMouseEvent, node: CanvasNodeType) => void;
|
onDragStart: (event: ReactMouseEvent, node: CanvasNodeType) => void;
|
||||||
onConnectStart: (id: string) => void;
|
onConnectStart: (id: string) => void;
|
||||||
onConnectEnd: (id: string) => void;
|
onConnectEnd: (id: string) => void;
|
||||||
@@ -62,6 +65,7 @@ export function CanvasNode({
|
|||||||
selected,
|
selected,
|
||||||
related,
|
related,
|
||||||
imageReferenceLabel,
|
imageReferenceLabel,
|
||||||
|
promptReferenceLabel,
|
||||||
activeConnecting,
|
activeConnecting,
|
||||||
connectionTarget,
|
connectionTarget,
|
||||||
promptMentionSources = [],
|
promptMentionSources = [],
|
||||||
@@ -80,7 +84,27 @@ export function CanvasNode({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const imageMetadata =
|
const imageMetadata =
|
||||||
node.type === "image" ? (node.metadata as CanvasImageNodeMetadata) : null;
|
node.type === "image" ? (node.metadata as CanvasImageNodeMetadata) : null;
|
||||||
|
const configDimensions = useMemo(
|
||||||
|
() =>
|
||||||
|
node.type === "config"
|
||||||
|
? getConfigNodeSize(node.title, node.metadata as CanvasConfigNodeMetadata)
|
||||||
|
: null,
|
||||||
|
[node.metadata, node.title, node.type],
|
||||||
|
);
|
||||||
const canShowImageToolbar = node.type === "image" && Boolean(imageMetadata?.imageUrl);
|
const canShowImageToolbar = node.type === "image" && Boolean(imageMetadata?.imageUrl);
|
||||||
|
const canShowPromptToolbar = node.type === "prompt";
|
||||||
|
const canShowConfigToolbar = node.type === "config";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!configDimensions) return;
|
||||||
|
if (
|
||||||
|
node.width === configDimensions.width &&
|
||||||
|
node.height === configDimensions.height
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onResize(node.id, configDimensions.width, configDimensions.height);
|
||||||
|
}, [configDimensions, node.height, node.id, node.width, onResize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -95,13 +119,13 @@ export function CanvasNode({
|
|||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${node.position.x}px, ${node.position.y}px)`,
|
transform: `translate(${node.position.x}px, ${node.position.y}px)`,
|
||||||
width: node.width,
|
width: configDimensions?.width ?? node.width,
|
||||||
height: node.height,
|
height: configDimensions?.height ?? node.height,
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => onDragStart(event, node)}
|
onMouseDown={(event) => onDragStart(event, node)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelect(node.id);
|
onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey);
|
||||||
if (connectionTarget) onConnectEnd(node.id);
|
if (connectionTarget) onConnectEnd(node.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -112,12 +136,14 @@ export function CanvasNode({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onGenerate={onGenerate}
|
onGenerate={onGenerate}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
promptReferenceLabel={promptReferenceLabel}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<NodeBody
|
<NodeBody
|
||||||
imageReferenceLabel={imageReferenceLabel}
|
imageReferenceLabel={imageReferenceLabel}
|
||||||
node={node}
|
node={node}
|
||||||
onOpenImagePreview={(image) => setPreviewImage(image)}
|
onOpenImagePreview={(image) => setPreviewImage(image)}
|
||||||
|
onGenerate={onGenerate}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
promptMentionSources={promptMentionSources}
|
promptMentionSources={promptMentionSources}
|
||||||
/>
|
/>
|
||||||
@@ -135,6 +161,23 @@ export function CanvasNode({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canShowPromptToolbar ? (
|
||||||
|
<PromptNodeToolbar
|
||||||
|
node={node}
|
||||||
|
visible={selected}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canShowConfigToolbar ? (
|
||||||
|
<ConfigNodeToolbar
|
||||||
|
node={node}
|
||||||
|
visible={selected}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ConnectionHandle
|
<ConnectionHandle
|
||||||
side="left"
|
side="left"
|
||||||
visible={activeConnecting || selected}
|
visible={activeConnecting || selected}
|
||||||
@@ -176,11 +219,13 @@ function NodeHeader({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
promptReferenceLabel,
|
||||||
}: {
|
}: {
|
||||||
node: CanvasNodeType;
|
node: CanvasNodeType;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onGenerate: (id: string) => void;
|
onGenerate: (id: string) => void;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
promptReferenceLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
|
||||||
@@ -193,6 +238,12 @@ function NodeHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{node.type === "prompt" && promptReferenceLabel ? (
|
||||||
|
<div className="shrink-0 rounded-full bg-[#ff4d4f] px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||||||
|
{promptReferenceLabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{node.type !== "prompt" && node.type !== "config" ? (
|
||||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -220,6 +271,7 @@ function NodeHeader({
|
|||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,12 +280,14 @@ function NodeBody({
|
|||||||
imageReferenceLabel,
|
imageReferenceLabel,
|
||||||
node,
|
node,
|
||||||
onOpenImagePreview,
|
onOpenImagePreview,
|
||||||
|
onGenerate,
|
||||||
onSelect,
|
onSelect,
|
||||||
promptMentionSources,
|
promptMentionSources,
|
||||||
}: {
|
}: {
|
||||||
imageReferenceLabel?: string;
|
imageReferenceLabel?: string;
|
||||||
node: CanvasNodeType;
|
node: CanvasNodeType;
|
||||||
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
onOpenImagePreview: (image: { title: string; url: string }) => void;
|
||||||
|
onGenerate: (id: string) => void;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
promptMentionSources: PromptMentionSource[];
|
promptMentionSources: PromptMentionSource[];
|
||||||
}) {
|
}) {
|
||||||
@@ -256,11 +310,13 @@ function NodeBody({
|
|||||||
if (node.type === "config") {
|
if (node.type === "config") {
|
||||||
const metadata = node.metadata as CanvasConfigNodeMetadata;
|
const metadata = node.metadata as CanvasConfigNodeMetadata;
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-0 flex-1 content-center gap-2 px-4 py-3 text-xs text-zinc-600">
|
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto px-5 py-5 text-xs text-zinc-600">
|
||||||
<ConfigLine label="Model" value={metadata.model} />
|
<div className="grid gap-3">
|
||||||
<ConfigLine label="Size" value={metadata.size} />
|
<ConfigLine label="模型" value={metadata.model} />
|
||||||
<ConfigLine label="Quality" value={metadata.quality} />
|
<ConfigLine label="尺寸" value={metadata.size} />
|
||||||
<ConfigLine label="Format" value={metadata.outputFormat} />
|
<ConfigLine label="质量" value={metadata.quality} />
|
||||||
|
<ConfigLine label="格式" value={metadata.outputFormat} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -321,7 +377,7 @@ function ImageNodeBody({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
className="h-full cursor-zoom-in overflow-hidden"
|
className="h-full cursor-grab overflow-hidden"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelect(node.id);
|
onSelect(node.id);
|
||||||
@@ -613,6 +669,91 @@ function ImageNodeToolbar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PromptNodeToolbar({
|
||||||
|
node,
|
||||||
|
visible,
|
||||||
|
onDelete,
|
||||||
|
onGenerate,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: CanvasNodeType;
|
||||||
|
visible: boolean;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onGenerate: (id: string) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute left-1/2 top-0 z-[70] flex -translate-x-1/2 -translate-y-[calc(100%+12px)] items-center gap-1 rounded-full border border-zinc-200 bg-white/95 px-3 py-2 shadow-[0_14px_38px_rgba(24,24,27,.16)] backdrop-blur-xl transition-opacity",
|
||||||
|
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
data-canvas-ui
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ImageToolbarButton
|
||||||
|
icon={<Info className="size-4" />}
|
||||||
|
label="信息"
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
/>
|
||||||
|
<ImageToolbarButton
|
||||||
|
danger
|
||||||
|
icon={<Trash2 className="size-4" />}
|
||||||
|
label="删除"
|
||||||
|
onClick={() => onDelete(node.id)}
|
||||||
|
/>
|
||||||
|
<ImageToolbarButton
|
||||||
|
icon={<Pencil className="size-4" />}
|
||||||
|
label="编辑"
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
/>
|
||||||
|
<ImageToolbarButton
|
||||||
|
icon={<Sparkles className="size-4" />}
|
||||||
|
label="生图"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(node.id);
|
||||||
|
onGenerate(node.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigNodeToolbar({
|
||||||
|
node,
|
||||||
|
visible,
|
||||||
|
onDelete,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: CanvasNodeType;
|
||||||
|
visible: boolean;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute left-1/2 top-0 z-[70] flex -translate-x-1/2 -translate-y-[calc(100%+12px)] items-center gap-1 rounded-full border border-zinc-200 bg-white/95 px-3 py-2 shadow-[0_14px_38px_rgba(24,24,27,.16)] backdrop-blur-xl transition-opacity",
|
||||||
|
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
data-canvas-ui
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ImageToolbarButton
|
||||||
|
icon={<Info className="size-4" />}
|
||||||
|
label="信息"
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
/>
|
||||||
|
<ImageToolbarButton
|
||||||
|
danger
|
||||||
|
icon={<Trash2 className="size-4" />}
|
||||||
|
label="删除"
|
||||||
|
onClick={() => onDelete(node.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ImageToolbarButton({
|
function ImageToolbarButton({
|
||||||
danger = false,
|
danger = false,
|
||||||
icon,
|
icon,
|
||||||
@@ -661,7 +802,7 @@ function getImageDownloadName(node: CanvasNodeType) {
|
|||||||
|
|
||||||
function ConfigLine({ label, value }: { label: string; value: string }) {
|
function ConfigLine({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg bg-zinc-100 px-3 py-2">
|
<div className="flex min-h-10 items-center justify-between gap-4 rounded-xl bg-zinc-100 px-4 py-2.5">
|
||||||
<span className="text-zinc-500">{label}</span>
|
<span className="text-zinc-500">{label}</span>
|
||||||
<span className="truncate font-medium text-zinc-800">{value}</span>
|
<span className="truncate font-medium text-zinc-800">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,15 +3,25 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus, RefreshCcw } from "lucide-react";
|
import { Pencil, Plus, RefreshCcw, Trash2, X } from "lucide-react";
|
||||||
|
import { toast, Toaster } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { CanvasProjectListItem } from "@/lib/canvas/types";
|
import type { CanvasProjectListItem } from "@/lib/canvas/types";
|
||||||
|
|
||||||
|
type EditingProjectState = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export function CanvasProjectsClient() {
|
export function CanvasProjectsClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
|
const [projects, setProjects] = useState<CanvasProjectListItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [editingProject, setEditingProject] = useState<EditingProjectState>(null);
|
||||||
|
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||||
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadProjects();
|
void loadProjects();
|
||||||
@@ -23,6 +33,8 @@ export function CanvasProjectsClient() {
|
|||||||
const response = await fetch("/api/canvas/projects", { cache: "no-store" });
|
const response = await fetch("/api/canvas/projects", { cache: "no-store" });
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
setProjects(Array.isArray(payload.items) ? payload.items : []);
|
setProjects(Array.isArray(payload.items) ? payload.items : []);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "加载画布失败");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -41,13 +53,82 @@ export function CanvasProjectsClient() {
|
|||||||
if (id) {
|
if (id) {
|
||||||
router.push(`/canvas/${id}`);
|
router.push(`/canvas/${id}`);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "新建画布失败");
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveProjectEdit() {
|
||||||
|
if (!editingProject || isSavingEdit) return;
|
||||||
|
const title = editingProject.title.trim();
|
||||||
|
if (!title) {
|
||||||
|
toast.error("标题不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingEdit(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/canvas/projects/${editingProject.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description: editingProject.description,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok || !payload.item) {
|
||||||
|
throw new Error(payload.error || "保存画布信息失败");
|
||||||
|
}
|
||||||
|
const updatedProject = payload.item as CanvasProjectListItem;
|
||||||
|
setProjects((current) =>
|
||||||
|
current.map((project) =>
|
||||||
|
project.id === updatedProject.id
|
||||||
|
? {
|
||||||
|
...project,
|
||||||
|
title: updatedProject.title,
|
||||||
|
description: updatedProject.description,
|
||||||
|
updatedAt: updatedProject.updatedAt,
|
||||||
|
}
|
||||||
|
: project,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditingProject(null);
|
||||||
|
toast.success("画布信息已更新");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "保存画布信息失败");
|
||||||
|
} finally {
|
||||||
|
setIsSavingEdit(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject(project: CanvasProjectListItem) {
|
||||||
|
const confirmed = window.confirm(`删除画布「${project.title}」?此操作不可撤销。`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setDeletingProjectId(project.id);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/canvas/projects/${project.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "删除画布失败");
|
||||||
|
}
|
||||||
|
setProjects((current) => current.filter((item) => item.id !== project.id));
|
||||||
|
toast.success("画布已删除");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "删除画布失败");
|
||||||
|
} finally {
|
||||||
|
setDeletingProjectId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
|
<div className="h-full overflow-auto bg-zinc-100 p-4 text-zinc-950 lg:p-6">
|
||||||
|
<Toaster richColors position="top-center" />
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
<header className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white p-4">
|
<header className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white p-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -73,17 +154,51 @@ export function CanvasProjectsClient() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Link
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
|
className="group rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-400 hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
className="min-w-0 flex-1"
|
||||||
href={`/canvas/${project.id}`}
|
href={`/canvas/${project.id}`}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium">{project.title}</div>
|
<div className="truncate text-sm font-medium">{project.title}</div>
|
||||||
<div className="mt-2 text-xs text-zinc-500">
|
<div className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-zinc-500">
|
||||||
|
{project.description || "暂无说明"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-zinc-500">
|
||||||
{project.nodeCount} 个节点 · 更新于{" "}
|
{project.nodeCount} 个节点 · 更新于{" "}
|
||||||
{new Date(project.updatedAt).toLocaleString()}
|
{new Date(project.updatedAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="flex shrink-0 gap-1 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||||
|
onClick={() =>
|
||||||
|
setEditingProject({
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
description: project.description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="编辑画布信息"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-red-50 hover:text-red-600 disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
disabled={deletingProjectId === project.id}
|
||||||
|
onClick={() => void deleteProject(project)}
|
||||||
|
title="删除画布"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{!projects.length ? (
|
{!projects.length ? (
|
||||||
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-6 text-sm text-zinc-500">
|
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-6 text-sm text-zinc-500">
|
||||||
@@ -93,6 +208,88 @@ export function CanvasProjectsClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ProjectEditDialog
|
||||||
|
project={editingProject}
|
||||||
|
isSaving={isSavingEdit}
|
||||||
|
onChange={setEditingProject}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isSavingEdit) setEditingProject(null);
|
||||||
|
}}
|
||||||
|
onSave={() => void saveProjectEdit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectEditDialog({
|
||||||
|
project,
|
||||||
|
isSaving,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
project: EditingProjectState;
|
||||||
|
isSaving: boolean;
|
||||||
|
onChange: (project: EditingProjectState) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) {
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/24 p-4 backdrop-blur-sm"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-lg rounded-xl bg-white p-5 shadow-[0_24px_80px_rgba(24,24,27,.22)] ring-1 ring-zinc-200">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h2 className="text-base font-semibold text-zinc-950">编辑画布信息</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onClose}
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="mt-5 block text-sm font-medium text-zinc-700">
|
||||||
|
标题
|
||||||
|
<input
|
||||||
|
className="mt-2 w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-zinc-400"
|
||||||
|
disabled={isSaving}
|
||||||
|
maxLength={80}
|
||||||
|
value={project.title}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...project, title: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="mt-4 block text-sm font-medium text-zinc-700">
|
||||||
|
说明
|
||||||
|
<textarea
|
||||||
|
className="mt-2 h-28 w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 outline-none transition focus:border-zinc-400"
|
||||||
|
disabled={isSaving}
|
||||||
|
maxLength={300}
|
||||||
|
placeholder="给这个画布补充用途、阶段或备注"
|
||||||
|
value={project.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...project, description: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" disabled={isSaving} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isSaving || !project.title.trim()} onClick={onSave}>
|
||||||
|
{isSaving ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Grid2X2,
|
Grid2X2,
|
||||||
Hand,
|
Hand,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
Keyboard,
|
||||||
Loader2,
|
Loader2,
|
||||||
Menu,
|
Menu,
|
||||||
Minus,
|
Minus,
|
||||||
@@ -51,6 +52,7 @@ type CanvasToolbarProps = {
|
|||||||
onResetView: () => void;
|
onResetView: () => void;
|
||||||
onToggleLibrary: () => void;
|
onToggleLibrary: () => void;
|
||||||
onToggleSelectionMode: () => void;
|
onToggleSelectionMode: () => void;
|
||||||
|
onShowShortcuts: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CanvasToolbar({
|
export function CanvasToolbar({
|
||||||
@@ -80,6 +82,7 @@ export function CanvasToolbar({
|
|||||||
onResetView,
|
onResetView,
|
||||||
onToggleLibrary,
|
onToggleLibrary,
|
||||||
onToggleSelectionMode,
|
onToggleSelectionMode,
|
||||||
|
onShowShortcuts,
|
||||||
}: CanvasToolbarProps) {
|
}: CanvasToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -106,6 +109,14 @@ export function CanvasToolbar({
|
|||||||
|
|
||||||
<div className="pointer-events-auto flex items-center gap-2 rounded-2xl bg-white/92 px-3 py-2.5 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
<div className="pointer-events-auto flex items-center gap-2 rounded-2xl bg-white/92 px-3 py-2.5 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
|
||||||
<StatusPill isGenerating={isGenerating} isSaving={isSaving} />
|
<StatusPill isGenerating={isGenerating} isSaving={isSaving} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-9 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||||
|
onClick={onShowShortcuts}
|
||||||
|
title="快捷键"
|
||||||
|
>
|
||||||
|
<Keyboard className="size-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex size-9 items-center justify-center rounded-full transition ${
|
className={`flex size-9 items-center justify-center rounded-full transition ${
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export function PromptMentionPreview({
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
src={segment.source.imageUrl}
|
src={segment.source.imageUrl}
|
||||||
/>
|
/>
|
||||||
|
) : segment.source.kind === "prompt" ? (
|
||||||
|
<span className="text-[10px] font-semibold text-zinc-600">文</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
|
|||||||
@@ -39,16 +39,15 @@ import {
|
|||||||
computeSizeFromRatio,
|
computeSizeFromRatio,
|
||||||
createClientRequestId,
|
createClientRequestId,
|
||||||
extensionFromFormat,
|
extensionFromFormat,
|
||||||
formatJobSuccessDescription,
|
|
||||||
getComputedSize,
|
getComputedSize,
|
||||||
normalizeGenerationError,
|
normalizeGenerationError,
|
||||||
normalizeImageJobPayload,
|
normalizeImageJobPayload,
|
||||||
parseJsonResponse,
|
parseJsonResponse,
|
||||||
|
watchImageJob,
|
||||||
type GenerationError,
|
type GenerationError,
|
||||||
type HistoryItem,
|
type HistoryItem,
|
||||||
type ImageJobPayload,
|
type ImageJobPayload,
|
||||||
type ImageMode,
|
type ImageMode,
|
||||||
type ImageJobStatus,
|
|
||||||
type Resolution,
|
type Resolution,
|
||||||
resolutionOptions,
|
resolutionOptions,
|
||||||
sizeOptions,
|
sizeOptions,
|
||||||
@@ -78,6 +77,13 @@ const promptPresets = [
|
|||||||
|
|
||||||
const defaultPrompt =
|
const defaultPrompt =
|
||||||
"温馨治愈的幼儿园毕业纪实写真,画面干净明亮通透,柔和室内自然光,人物边缘清晰,色彩低饱和且高级,真实摄影质感,无文字水印。";
|
"温馨治愈的幼儿园毕业纪实写真,画面干净明亮通透,柔和室内自然光,人物边缘清晰,色彩低饱和且高级,真实摄影质感,无文字水印。";
|
||||||
|
const activeDirectJobStorageKey = "imagegen:direct:active-job";
|
||||||
|
|
||||||
|
type StoredDirectJob = {
|
||||||
|
jobId: string;
|
||||||
|
clientRequestId: string;
|
||||||
|
requestStartedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||||
const [mode, setMode] = useState<ImageMode>("generate");
|
const [mode, setMode] = useState<ImageMode>("generate");
|
||||||
@@ -100,6 +106,7 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
|||||||
const [previewImage, setPreviewImage] = useState<PreviewImage | null>(null);
|
const [previewImage, setPreviewImage] = useState<PreviewImage | null>(null);
|
||||||
const [generationError, setGenerationError] = useState<GenerationError | null>(null);
|
const [generationError, setGenerationError] = useState<GenerationError | null>(null);
|
||||||
const [currentJob, setCurrentJob] = useState<ImageJobPayload | null>(null);
|
const [currentJob, setCurrentJob] = useState<ImageJobPayload | null>(null);
|
||||||
|
const jobWatcherCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const filePreviewUrl = useObjectUrl(file);
|
const filePreviewUrl = useObjectUrl(file);
|
||||||
const maskPreviewUrl = useObjectUrl(maskFile);
|
const maskPreviewUrl = useObjectUrl(maskFile);
|
||||||
@@ -130,6 +137,53 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
void loadSettings();
|
void loadSettings();
|
||||||
|
|
||||||
|
const storedJob = readStoredDirectJob();
|
||||||
|
if (storedJob) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
setGenerationError(null);
|
||||||
|
void watchDirectImageJob(
|
||||||
|
storedJob.jobId,
|
||||||
|
storedJob.requestStartedAt,
|
||||||
|
storedJob.clientRequestId,
|
||||||
|
)
|
||||||
|
.then(async (completedJob) => {
|
||||||
|
if (completedJob.status === "failed") {
|
||||||
|
throw buildGenerationErrorFromJob(
|
||||||
|
completedJob,
|
||||||
|
storedJob.requestStartedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await loadHistory();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const generationFailure = normalizeGenerationError(
|
||||||
|
error,
|
||||||
|
storedJob.requestStartedAt,
|
||||||
|
storedJob.clientRequestId,
|
||||||
|
);
|
||||||
|
setGenerationError(generationFailure);
|
||||||
|
toast.error(generationFailure.title, {
|
||||||
|
description: generationFailure.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsGenerating(false);
|
||||||
|
clearStoredDirectJob();
|
||||||
|
setCurrentJob((job) =>
|
||||||
|
job?.status === "succeeded" || job?.status === "failed"
|
||||||
|
? job
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
jobWatcherCleanupRef.current?.();
|
||||||
|
jobWatcherCleanupRef.current = null;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
@@ -240,16 +294,18 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
|||||||
toast.success("图片任务已提交", {
|
toast.success("图片任务已提交", {
|
||||||
description: jobId ? `任务 ID:${jobId}` : undefined,
|
description: jobId ? `任务 ID:${jobId}` : undefined,
|
||||||
});
|
});
|
||||||
|
writeStoredDirectJob({ jobId, clientRequestId, requestStartedAt });
|
||||||
|
|
||||||
const completedJob = await pollImageJob(jobId, requestStartedAt);
|
const completedJob = await watchDirectImageJob(
|
||||||
|
jobId,
|
||||||
|
requestStartedAt,
|
||||||
|
clientRequestId,
|
||||||
|
);
|
||||||
if (completedJob.status === "failed") {
|
if (completedJob.status === "failed") {
|
||||||
throw buildGenerationErrorFromJob(completedJob, requestStartedAt);
|
throw buildGenerationErrorFromJob(completedJob, requestStartedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
toast.success("图片已生成", {
|
|
||||||
description: formatJobSuccessDescription(completedJob),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const generationFailure = normalizeGenerationError(
|
const generationFailure = normalizeGenerationError(
|
||||||
error,
|
error,
|
||||||
@@ -262,40 +318,36 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
|
clearStoredDirectJob();
|
||||||
setCurrentJob((job) =>
|
setCurrentJob((job) =>
|
||||||
job?.status === "succeeded" || job?.status === "failed" ? job : null,
|
job?.status === "succeeded" || job?.status === "failed" ? job : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollImageJob(jobId: string, requestStartedAt: number) {
|
function watchDirectImageJob(
|
||||||
const maxPolls = 240;
|
jobId: string,
|
||||||
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
requestStartedAt: number,
|
||||||
await wait(attempt < 2 ? 1200 : 3000);
|
clientRequestId: string,
|
||||||
const response = await fetch(
|
) {
|
||||||
`/api/images?jobId=${encodeURIComponent(jobId)}`,
|
jobWatcherCleanupRef.current?.();
|
||||||
{ cache: "no-store" },
|
|
||||||
);
|
|
||||||
const payload = await parseJsonResponse(response);
|
|
||||||
const job = normalizeImageJobPayload(payload);
|
|
||||||
setCurrentJob(job);
|
|
||||||
|
|
||||||
if (!response.ok && job.status !== "failed") {
|
return new Promise<ImageJobPayload>((resolve, reject) => {
|
||||||
throw buildGenerationError(response, payload, requestStartedAt);
|
jobWatcherCleanupRef.current = watchImageJob({
|
||||||
}
|
jobId,
|
||||||
|
onJob: setCurrentJob,
|
||||||
if (job.status === "succeeded" || job.status === "failed") {
|
onComplete: (job) => {
|
||||||
return job;
|
clearStoredDirectJob();
|
||||||
}
|
jobWatcherCleanupRef.current = null;
|
||||||
}
|
resolve(job);
|
||||||
|
},
|
||||||
throw {
|
onError: (error) => {
|
||||||
title: "生成超时",
|
clearStoredDirectJob();
|
||||||
message: "轮询已超过 12 分钟,任务可能仍在后台运行,请稍后查看历史记录。",
|
jobWatcherCleanupRef.current = null;
|
||||||
requestId: currentJob?.requestId,
|
reject(normalizeGenerationError(error, requestStartedAt, clientRequestId));
|
||||||
phase: currentJob?.phase,
|
},
|
||||||
durationMs: Math.round(performance.now() - requestStartedAt),
|
});
|
||||||
} satisfies GenerationError;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectHistoryItem(item: HistoryItem) {
|
function selectHistoryItem(item: HistoryItem) {
|
||||||
@@ -1034,6 +1086,29 @@ function getStringField(payload: Record<string, unknown>, key: string) {
|
|||||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(ms: number) {
|
function readStoredDirectJob() {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(activeDirectJobStorageKey);
|
||||||
|
if (!raw) return null;
|
||||||
|
const payload = JSON.parse(raw) as Partial<StoredDirectJob>;
|
||||||
|
if (!payload.jobId || !payload.clientRequestId) return null;
|
||||||
|
return {
|
||||||
|
jobId: payload.jobId,
|
||||||
|
clientRequestId: payload.clientRequestId,
|
||||||
|
requestStartedAt:
|
||||||
|
typeof payload.requestStartedAt === "number"
|
||||||
|
? payload.requestStartedAt
|
||||||
|
: performance.now(),
|
||||||
|
} satisfies StoredDirectJob;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredDirectJob(job: StoredDirectJob) {
|
||||||
|
window.sessionStorage.setItem(activeDirectJobStorageKey, JSON.stringify(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredDirectJob() {
|
||||||
|
window.sessionStorage.removeItem(activeDirectJobStorageKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from "@/lib/canvas/types";
|
} from "@/lib/canvas/types";
|
||||||
import { defaultCanvasConfig } from "@/lib/canvas/node-factory";
|
import { defaultCanvasConfig } from "@/lib/canvas/node-factory";
|
||||||
import {
|
import {
|
||||||
|
getConnectedPromptSources,
|
||||||
getPromptMentionSources,
|
getPromptMentionSources,
|
||||||
resolvePromptMentions,
|
resolvePromptMentions,
|
||||||
} from "@/lib/canvas/prompt-mentions";
|
} from "@/lib/canvas/prompt-mentions";
|
||||||
@@ -32,10 +33,31 @@ export function resolveCanvasGenerationInput(
|
|||||||
scopedNodeIds.has(connection.fromNodeId) &&
|
scopedNodeIds.has(connection.fromNodeId) &&
|
||||||
scopedNodeIds.has(connection.toNodeId),
|
scopedNodeIds.has(connection.toNodeId),
|
||||||
);
|
);
|
||||||
const promptNode =
|
const connectedPromptSources = selectedNodeId
|
||||||
|
? getConnectedPromptSources(scopedNodes, scopedConnections, selectedNodeId)
|
||||||
|
: [];
|
||||||
|
const connectedPrompts = connectedPromptSources.filter((source) =>
|
||||||
|
source.prompt?.trim(),
|
||||||
|
);
|
||||||
|
const connectedPromptNode = connectedPrompts[0]
|
||||||
|
? scopedNodes.find((node) => node.id === connectedPrompts[0].nodeId)
|
||||||
|
: null;
|
||||||
|
const selectedNode = selectedNodeId
|
||||||
|
? scopedNodes.find((node) => node.id === selectedNodeId)
|
||||||
|
: null;
|
||||||
|
const selectedImagePrompt =
|
||||||
|
selectedNode?.type === "image"
|
||||||
|
? ((selectedNode.metadata as CanvasImageNodeMetadata).prompt || "").trim()
|
||||||
|
: "";
|
||||||
|
const promptNode = selectedImagePrompt
|
||||||
|
? selectedNode
|
||||||
|
: connectedPromptNode ??
|
||||||
scopedNodes.find((node) => node.type === "prompt") ??
|
scopedNodes.find((node) => node.type === "prompt") ??
|
||||||
nodes.find((node) => node.type === "prompt");
|
nodes.find((node) => node.type === "prompt");
|
||||||
const rawPrompt = promptNode
|
const rawPrompt =
|
||||||
|
promptNode?.type === "image"
|
||||||
|
? ((promptNode.metadata as CanvasImageNodeMetadata).prompt || "").trim()
|
||||||
|
: promptNode?.type === "prompt"
|
||||||
? ((promptNode.metadata as CanvasPromptNodeMetadata).prompt || "").trim()
|
? ((promptNode.metadata as CanvasPromptNodeMetadata).prompt || "").trim()
|
||||||
: "";
|
: "";
|
||||||
const resolvedMentions = promptNode
|
const resolvedMentions = promptNode
|
||||||
@@ -44,10 +66,14 @@ export function resolveCanvasGenerationInput(
|
|||||||
getPromptMentionSources(scopedNodes, scopedConnections, promptNode.id),
|
getPromptMentionSources(scopedNodes, scopedConnections, promptNode.id),
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const prompt = resolvedMentions?.prompt ?? "";
|
const prompt = selectedImagePrompt
|
||||||
|
? (resolvedMentions?.prompt ?? "")
|
||||||
|
: connectedPrompts.length
|
||||||
|
? formatConnectedPrompts(connectedPrompts)
|
||||||
|
: (resolvedMentions?.prompt ?? "");
|
||||||
|
|
||||||
if (!promptNode || !prompt) {
|
if (!promptNode || !prompt) {
|
||||||
return { error: "请先添加并填写提示词节点" };
|
return { error: "请先添加并填写提示词节点或当前图像节点提示词" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const configNode =
|
const configNode =
|
||||||
@@ -56,10 +82,10 @@ export function resolveCanvasGenerationInput(
|
|||||||
const imageNodes = collectImageNodes(
|
const imageNodes = collectImageNodes(
|
||||||
scopedNodes,
|
scopedNodes,
|
||||||
resolvedMentions?.referencedNodeIds ?? [],
|
resolvedMentions?.referencedNodeIds ?? [],
|
||||||
);
|
).filter((node) => node.id !== promptNode.id);
|
||||||
const fallbackImageNodes = imageNodes.length
|
const fallbackImageNodes = imageNodes.length
|
||||||
? imageNodes
|
? imageNodes
|
||||||
: collectImageNodes(nodes, []);
|
: collectImageNodes(nodes, []).filter((node) => node.id !== promptNode.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
promptNode,
|
promptNode,
|
||||||
@@ -70,10 +96,31 @@ export function resolveCanvasGenerationInput(
|
|||||||
config: {
|
config: {
|
||||||
...defaultCanvasConfig,
|
...defaultCanvasConfig,
|
||||||
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
|
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
|
||||||
|
...getImageGenerationConfig(promptNode),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getImageGenerationConfig(node: CanvasNode | undefined) {
|
||||||
|
if (node?.type !== "image") return {};
|
||||||
|
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||||
|
return {
|
||||||
|
...(metadata.model ? { model: metadata.model } : {}),
|
||||||
|
...(metadata.size ? { size: metadata.size } : {}),
|
||||||
|
...(metadata.quality ? { quality: metadata.quality } : {}),
|
||||||
|
...(metadata.outputFormat ? { outputFormat: metadata.outputFormat } : {}),
|
||||||
|
} satisfies Partial<CanvasConfigNodeMetadata>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConnectedPrompts(
|
||||||
|
promptSources: ReturnType<typeof getConnectedPromptSources>,
|
||||||
|
) {
|
||||||
|
return promptSources
|
||||||
|
.filter((source) => source.prompt?.trim())
|
||||||
|
.map((source) => `${source.label}:${source.prompt?.trim()}`)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
function collectImageNodes(nodes: CanvasNode[], preferredNodeIds: string[]) {
|
function collectImageNodes(nodes: CanvasNode[], preferredNodeIds: string[]) {
|
||||||
const preferred = preferredNodeIds
|
const preferred = preferredNodeIds
|
||||||
.map((id) => nodes.find((node) => node.id === id))
|
.map((id) => nodes.find((node) => node.id === id))
|
||||||
|
|||||||
@@ -41,13 +41,14 @@ export function createCanvasNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "config") {
|
if (type === "config") {
|
||||||
|
const dimensions = getConfigNodeSize("生成配置", defaultCanvasConfig);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
title: "生成配置",
|
title: "生成配置",
|
||||||
position,
|
position,
|
||||||
width: DEFAULT_NODE_WIDTH,
|
width: dimensions.width,
|
||||||
height: DEFAULT_NODE_HEIGHT,
|
height: dimensions.height,
|
||||||
metadata: { ...defaultCanvasConfig },
|
metadata: { ...defaultCanvasConfig },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -141,6 +142,84 @@ export function createImageResultNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createImageLoadingNode(position: CanvasPosition): CanvasNode {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "image",
|
||||||
|
title: "生成结果",
|
||||||
|
position,
|
||||||
|
width: DEFAULT_IMAGE_WIDTH,
|
||||||
|
height: DEFAULT_IMAGE_HEIGHT,
|
||||||
|
metadata: {
|
||||||
|
imageUrl: "",
|
||||||
|
mode: "result",
|
||||||
|
status: "loading",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigNodeSize(
|
||||||
|
title: string,
|
||||||
|
metadata: CanvasConfigNodeMetadata,
|
||||||
|
) {
|
||||||
|
const labels = ["模型", "尺寸", "质量", "格式"];
|
||||||
|
const values = [
|
||||||
|
metadata.model,
|
||||||
|
metadata.size,
|
||||||
|
metadata.quality,
|
||||||
|
metadata.outputFormat,
|
||||||
|
];
|
||||||
|
const labelWidth = Math.max(...labels.map(getApproxTextWidth));
|
||||||
|
const valueWidth = Math.max(...values.map(getApproxTextWidth));
|
||||||
|
const titleWidth = getApproxTextWidth(title);
|
||||||
|
|
||||||
|
const horizontalPadding = 48;
|
||||||
|
const rowPadding = 32;
|
||||||
|
const rowGap = 16;
|
||||||
|
const iconAndTitleGap = 28;
|
||||||
|
const width = clamp(
|
||||||
|
Math.ceil(
|
||||||
|
Math.max(
|
||||||
|
titleWidth + iconAndTitleGap + horizontalPadding,
|
||||||
|
labelWidth + valueWidth + rowPadding + rowGap + horizontalPadding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
340,
|
||||||
|
560,
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerHeight = 58;
|
||||||
|
const bodyPadding = 40;
|
||||||
|
const rowHeight = 40;
|
||||||
|
const rowCount = labels.length;
|
||||||
|
const rowGaps = 12 * (rowCount - 1);
|
||||||
|
const generateButtonHeight = 40;
|
||||||
|
const generateButtonMargin = 8;
|
||||||
|
const height =
|
||||||
|
headerHeight +
|
||||||
|
bodyPadding +
|
||||||
|
rowHeight * rowCount +
|
||||||
|
rowGaps +
|
||||||
|
generateButtonMargin +
|
||||||
|
generateButtonHeight;
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApproxTextWidth(text: string) {
|
||||||
|
return Array.from(text || "").reduce((width, char) => {
|
||||||
|
if (/[\u4e00-\u9fff]/.test(char)) return width + 14;
|
||||||
|
if (/[A-Z0-9]/.test(char)) return width + 8.5;
|
||||||
|
if (/[mw@#%&]/i.test(char)) return width + 9;
|
||||||
|
if (/[\s._-]/.test(char)) return width + 4.5;
|
||||||
|
return width + 7.5;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
function getImageNodeSize(item: Pick<HistoryItem, "size">) {
|
function getImageNodeSize(item: Pick<HistoryItem, "size">) {
|
||||||
const match = /^(\d+)x(\d+)$/.exec(item.size || "");
|
const match = /^(\d+)x(\d+)$/.exec(item.size || "");
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|||||||
@@ -2,59 +2,126 @@ import type {
|
|||||||
CanvasConnection,
|
CanvasConnection,
|
||||||
CanvasImageNodeMetadata,
|
CanvasImageNodeMetadata,
|
||||||
CanvasNode,
|
CanvasNode,
|
||||||
|
CanvasPromptNodeMetadata,
|
||||||
} from "@/lib/canvas/types";
|
} from "@/lib/canvas/types";
|
||||||
|
|
||||||
export type PromptMentionSource = {
|
export type PromptMentionSource = {
|
||||||
alias: string;
|
alias: string;
|
||||||
token: string;
|
token: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
kind: "image" | "prompt";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
imageUrl: string;
|
imageUrl?: string;
|
||||||
|
prompt?: string;
|
||||||
index: number;
|
index: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mentionUrlPrefix = "canvas-image://";
|
const imageMentionUrlPrefix = "canvas-image://";
|
||||||
|
const promptMentionUrlPrefix = "canvas-prompt://";
|
||||||
|
|
||||||
export function getPromptMentionSources(
|
export function getPromptMentionSources(
|
||||||
nodes: CanvasNode[],
|
nodes: CanvasNode[],
|
||||||
connections: CanvasConnection[],
|
connections: CanvasConnection[],
|
||||||
promptNodeId: string,
|
promptNodeId: string,
|
||||||
) {
|
): PromptMentionSource[] {
|
||||||
const imageById = new Map(
|
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
|
||||||
|
const sources: PromptMentionSource[] = [];
|
||||||
|
let imageIndex = 0;
|
||||||
|
let promptIndex = 0;
|
||||||
|
|
||||||
|
for (const connection of connections) {
|
||||||
|
if (connection.toNodeId !== promptNodeId) continue;
|
||||||
|
|
||||||
|
const node = nodeById.get(connection.fromNodeId);
|
||||||
|
if (!node) continue;
|
||||||
|
|
||||||
|
if (node.type === "prompt") {
|
||||||
|
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
||||||
|
const prompt = metadata.prompt?.trim() || "";
|
||||||
|
promptIndex += 1;
|
||||||
|
sources.push({
|
||||||
|
alias: `@文本${promptIndex}`,
|
||||||
|
token: createPromptMentionToken(`文本${promptIndex}`, node.id, "prompt"),
|
||||||
|
nodeId: node.id,
|
||||||
|
kind: "prompt",
|
||||||
|
label: `文本${promptIndex}`,
|
||||||
|
index: promptIndex,
|
||||||
|
description: prompt || node.title.trim() || `已连接文本 ${promptIndex}`,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "image") {
|
||||||
|
const metadata = node.metadata as CanvasImageNodeMetadata;
|
||||||
|
if (!metadata.imageUrl) continue;
|
||||||
|
imageIndex += 1;
|
||||||
|
sources.push({
|
||||||
|
alias: `@图${imageIndex}`,
|
||||||
|
token: createPromptMentionToken(`图${imageIndex}`, node.id, "image"),
|
||||||
|
nodeId: node.id,
|
||||||
|
kind: "image",
|
||||||
|
label: `图${imageIndex}`,
|
||||||
|
index: imageIndex,
|
||||||
|
description:
|
||||||
|
metadata.prompt?.trim() || node.title.trim() || `已连接素材 ${imageIndex}`,
|
||||||
|
imageUrl: metadata.imageUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectedPromptSources(
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
connections: CanvasConnection[],
|
||||||
|
targetNodeId: string,
|
||||||
|
): PromptMentionSource[] {
|
||||||
|
const promptById = new Map(
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "image")
|
.filter((node) => node.type === "prompt")
|
||||||
.map((node) => [node.id, node] as const),
|
.map((node) => [node.id, node] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
return connections
|
return connections
|
||||||
.filter((connection) => connection.toNodeId === promptNodeId)
|
.filter((connection) => connection.toNodeId === targetNodeId)
|
||||||
.map((connection) => imageById.get(connection.fromNodeId))
|
.map((connection) => promptById.get(connection.fromNodeId))
|
||||||
.filter((node): node is CanvasNode => Boolean(node))
|
.filter((node): node is CanvasNode => Boolean(node))
|
||||||
.map((node, index) => {
|
.map((node, index) => {
|
||||||
const metadata = node.metadata as CanvasImageNodeMetadata;
|
const metadata = node.metadata as CanvasPromptNodeMetadata;
|
||||||
|
const prompt = metadata.prompt?.trim() || "";
|
||||||
return {
|
return {
|
||||||
alias: `@图${index + 1}`,
|
alias: `@文本${index + 1}`,
|
||||||
token: createPromptMentionToken(`图${index + 1}`, node.id),
|
token: createPromptMentionToken(`文本${index + 1}`, node.id, "prompt"),
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
label: `图${index + 1}`,
|
kind: "prompt",
|
||||||
|
label: `文本${index + 1}`,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
description:
|
description: prompt || node.title.trim() || `已连接文本 ${index + 1}`,
|
||||||
metadata.prompt?.trim() || node.title.trim() || `已连接素材 ${index + 1}`,
|
prompt,
|
||||||
imageUrl: metadata.imageUrl,
|
|
||||||
} satisfies PromptMentionSource;
|
} satisfies PromptMentionSource;
|
||||||
})
|
});
|
||||||
.filter((item) => Boolean(item.imageUrl));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptMentionToken(label: string, nodeId: string) {
|
export function createPromptMentionToken(
|
||||||
return `[${label}](${mentionUrlPrefix}${nodeId})`;
|
label: string,
|
||||||
|
nodeId: string,
|
||||||
|
kind: PromptMentionSource["kind"] = "image",
|
||||||
|
) {
|
||||||
|
const prefix = kind === "prompt" ? promptMentionUrlPrefix : imageMentionUrlPrefix;
|
||||||
|
return `[${label}](${prefix}${nodeId})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePromptMentionUrl(value: string) {
|
export function parsePromptMentionUrl(value: string) {
|
||||||
return value.startsWith(mentionUrlPrefix)
|
if (value.startsWith(imageMentionUrlPrefix)) {
|
||||||
? value.slice(mentionUrlPrefix.length)
|
return value.slice(imageMentionUrlPrefix.length);
|
||||||
: null;
|
}
|
||||||
|
if (value.startsWith(promptMentionUrlPrefix)) {
|
||||||
|
return value.slice(promptMentionUrlPrefix.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePromptMentions(
|
export function resolvePromptMentions(
|
||||||
@@ -65,7 +132,10 @@ export function resolvePromptMentions(
|
|||||||
let resolvedPrompt = prompt;
|
let resolvedPrompt = prompt;
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
const replacement = `${mention.label}(第 ${mention.index} 张输入参考图)`;
|
const replacement =
|
||||||
|
mention.kind === "image"
|
||||||
|
? `${mention.label}(第 ${mention.index} 张输入参考图)`
|
||||||
|
: `${mention.label}(第 ${mention.index} 段输入文本)`;
|
||||||
const usedMarkdownToken = resolvedPrompt.includes(mention.token);
|
const usedMarkdownToken = resolvedPrompt.includes(mention.token);
|
||||||
const usedAlias = resolvedPrompt.includes(mention.alias);
|
const usedAlias = resolvedPrompt.includes(mention.alias);
|
||||||
if (!usedMarkdownToken && !usedAlias) continue;
|
if (!usedMarkdownToken && !usedAlias) continue;
|
||||||
@@ -75,13 +145,25 @@ export function resolvePromptMentions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mentions.length > 0) {
|
if (mentions.length > 0) {
|
||||||
const referenceGuide = mentions
|
const imageGuide = mentions
|
||||||
|
.filter((mention) => mention.kind === "image")
|
||||||
.map(
|
.map(
|
||||||
(mention) =>
|
(mention) =>
|
||||||
`${mention.label} = 第 ${mention.index} 张输入参考图:${mention.description}`,
|
`${mention.label} = 第 ${mention.index} 张输入参考图:${mention.description}`,
|
||||||
)
|
);
|
||||||
.join("\n");
|
const promptGuide = mentions
|
||||||
resolvedPrompt = `${resolvedPrompt.trim()}\n\n已连接参考图编号:\n${referenceGuide}`;
|
.filter((mention) => mention.kind === "prompt")
|
||||||
|
.map(
|
||||||
|
(mention) =>
|
||||||
|
`${mention.label} = 第 ${mention.index} 段输入文本:${mention.description}`,
|
||||||
|
);
|
||||||
|
const guide = [
|
||||||
|
imageGuide.length ? `已连接参考图编号:\n${imageGuide.join("\n")}` : "",
|
||||||
|
promptGuide.length ? `已连接文本编号:\n${promptGuide.join("\n")}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
resolvedPrompt = `${resolvedPrompt.trim()}\n\n${guide}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export type CanvasProjectData = {
|
|||||||
export type CanvasProjectRecord = {
|
export type CanvasProjectRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string;
|
||||||
data: CanvasProjectData;
|
data: CanvasProjectData;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -84,6 +85,7 @@ export type CanvasProjectRecord = {
|
|||||||
export type CanvasProjectListItem = {
|
export type CanvasProjectListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
nodeCount: number;
|
nodeCount: number;
|
||||||
|
|||||||
@@ -44,7 +44,14 @@ export type ImageJobPayload = {
|
|||||||
upstreamStatus?: number;
|
upstreamStatus?: number;
|
||||||
upstreamRequestId?: string;
|
upstreamRequestId?: string;
|
||||||
details?: unknown;
|
details?: unknown;
|
||||||
pollUrl?: string;
|
eventsUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WatchImageJobOptions = {
|
||||||
|
jobId: string;
|
||||||
|
onJob: (job: ImageJobPayload) => void;
|
||||||
|
onComplete: (job: ImageJobPayload) => void;
|
||||||
|
onError: (error: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sizeOptions = [
|
export const sizeOptions = [
|
||||||
@@ -281,10 +288,48 @@ export function normalizeImageJobPayload(
|
|||||||
upstreamStatus: getNumberField(payload, "upstreamStatus"),
|
upstreamStatus: getNumberField(payload, "upstreamStatus"),
|
||||||
upstreamRequestId: getStringField(payload, "upstreamRequestId"),
|
upstreamRequestId: getStringField(payload, "upstreamRequestId"),
|
||||||
details: payload.details,
|
details: payload.details,
|
||||||
pollUrl: getStringField(payload, "pollUrl"),
|
eventsUrl: getStringField(payload, "eventsUrl"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function watchImageJob({
|
||||||
|
jobId,
|
||||||
|
onJob,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: WatchImageJobOptions) {
|
||||||
|
const source = new EventSource(
|
||||||
|
`/api/images/events?jobId=${encodeURIComponent(jobId)}`,
|
||||||
|
);
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
source.addEventListener("job", (event) => {
|
||||||
|
const payload = parseSsePayload(event);
|
||||||
|
const job = normalizeImageJobPayload(payload);
|
||||||
|
onJob(job);
|
||||||
|
|
||||||
|
if (job.status === "succeeded" || job.status === "failed") {
|
||||||
|
settled = true;
|
||||||
|
source.close();
|
||||||
|
onComplete(job);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener("error", (event) => {
|
||||||
|
const payload = parseSsePayload(event);
|
||||||
|
settled = true;
|
||||||
|
source.close();
|
||||||
|
onError(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
if (settled || source.readyState !== EventSource.CLOSED) return;
|
||||||
|
onError(new Error("生成任务监听已断开"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => source.close();
|
||||||
|
}
|
||||||
|
|
||||||
export function formatJobSuccessDescription(job: ImageJobPayload) {
|
export function formatJobSuccessDescription(job: ImageJobPayload) {
|
||||||
return [
|
return [
|
||||||
job.jobId ? `任务 ID:${job.jobId}` : null,
|
job.jobId ? `任务 ID:${job.jobId}` : null,
|
||||||
@@ -327,3 +372,13 @@ function isGenerationError(value: unknown): value is GenerationError {
|
|||||||
"message" in value
|
"message" in value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSsePayload(event: Event) {
|
||||||
|
if (!("data" in event) || typeof event.data !== "string") return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.data) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return { error: event.data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,12 +43,20 @@ export function getDb() {
|
|||||||
CREATE TABLE IF NOT EXISTS canvas_projects (
|
CREATE TABLE IF NOT EXISTS canvas_projects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const canvasProjectColumns = db
|
||||||
|
.prepare("PRAGMA table_info(canvas_projects)")
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
if (!canvasProjectColumns.some((column) => column.name === "description")) {
|
||||||
|
db.exec("ALTER TABLE canvas_projects ADD COLUMN description TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('retentionDays', '7')",
|
"INSERT OR IGNORE INTO settings (key, value) VALUES ('retentionDays', '7')",
|
||||||
).run();
|
).run();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getDb } from "@/lib/server/db";
|
|||||||
type CanvasProjectRow = {
|
type CanvasProjectRow = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string;
|
||||||
data: string;
|
data: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -18,7 +19,7 @@ type CanvasProjectRow = {
|
|||||||
export function listCanvasProjects(): CanvasProjectListItem[] {
|
export function listCanvasProjects(): CanvasProjectListItem[] {
|
||||||
const rows = getDb()
|
const rows = getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
|
`SELECT id, title, description, data, created_at AS createdAt, updated_at AS updatedAt
|
||||||
FROM canvas_projects
|
FROM canvas_projects
|
||||||
ORDER BY updated_at DESC`,
|
ORDER BY updated_at DESC`,
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,7 @@ export function listCanvasProjects(): CanvasProjectListItem[] {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
nodeCount: data.nodes.length,
|
nodeCount: data.nodes.length,
|
||||||
@@ -39,7 +41,7 @@ export function listCanvasProjects(): CanvasProjectListItem[] {
|
|||||||
export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
|
export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
|
||||||
const row = getDb()
|
const row = getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, title, data, created_at AS createdAt, updated_at AS updatedAt
|
`SELECT id, title, description, data, created_at AS createdAt, updated_at AS updatedAt
|
||||||
FROM canvas_projects
|
FROM canvas_projects
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
@@ -52,6 +54,7 @@ export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
data: parseCanvasProjectData(row.data),
|
data: parseCanvasProjectData(row.data),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -60,19 +63,21 @@ export function getCanvasProjectById(id: string): CanvasProjectRecord | null {
|
|||||||
|
|
||||||
export function createCanvasProject(input?: {
|
export function createCanvasProject(input?: {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
data?: CanvasProjectData;
|
data?: CanvasProjectData;
|
||||||
}) {
|
}) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const title = input?.title?.trim() || "未命名画布";
|
const title = input?.title?.trim() || "未命名画布";
|
||||||
|
const description = input?.description?.trim() || "";
|
||||||
const data = normalizeCanvasProjectData(input?.data) ?? defaultCanvasProjectData;
|
const data = normalizeCanvasProjectData(input?.data) ?? defaultCanvasProjectData;
|
||||||
|
|
||||||
getDb()
|
getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO canvas_projects (id, title, data, created_at, updated_at)
|
`INSERT INTO canvas_projects (id, title, description, data, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.run(id, title, JSON.stringify(data), now, now);
|
.run(id, title, description, JSON.stringify(data), now, now);
|
||||||
|
|
||||||
return getCanvasProjectById(id);
|
return getCanvasProjectById(id);
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,7 @@ export function updateCanvasProject(
|
|||||||
id: string,
|
id: string,
|
||||||
patch: {
|
patch: {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
data?: CanvasProjectData;
|
data?: CanvasProjectData;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -88,16 +94,20 @@ export function updateCanvasProject(
|
|||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
const title = patch.title?.trim() || current.title;
|
const title = patch.title?.trim() || current.title;
|
||||||
|
const description =
|
||||||
|
typeof patch.description === "string"
|
||||||
|
? patch.description.trim()
|
||||||
|
: current.description;
|
||||||
const data = normalizeCanvasProjectData(patch.data) ?? current.data;
|
const data = normalizeCanvasProjectData(patch.data) ?? current.data;
|
||||||
const updatedAt = new Date().toISOString();
|
const updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
getDb()
|
getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE canvas_projects
|
`UPDATE canvas_projects
|
||||||
SET title = ?, data = ?, updated_at = ?
|
SET title = ?, description = ?, data = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.run(title, JSON.stringify(data), updatedAt, id);
|
.run(title, description, JSON.stringify(data), updatedAt, id);
|
||||||
|
|
||||||
return getCanvasProjectById(id);
|
return getCanvasProjectById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ export type CanvasProjectExportV1 = {
|
|||||||
version: typeof CANVAS_EXPORT_VERSION;
|
version: typeof CANVAS_EXPORT_VERSION;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string;
|
||||||
data: CanvasProjectData;
|
data: CanvasProjectData;
|
||||||
assets: CanvasProjectExportAsset[];
|
assets: CanvasProjectExportAsset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createCanvasProjectExport(input: {
|
export function createCanvasProjectExport(input: {
|
||||||
title: string;
|
title: string;
|
||||||
|
description?: string;
|
||||||
data: CanvasProjectData;
|
data: CanvasProjectData;
|
||||||
}): CanvasProjectExportV1 {
|
}): CanvasProjectExportV1 {
|
||||||
return {
|
return {
|
||||||
@@ -37,6 +39,7 @@ export function createCanvasProjectExport(input: {
|
|||||||
version: CANVAS_EXPORT_VERSION,
|
version: CANVAS_EXPORT_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
title: input.title,
|
title: input.title,
|
||||||
|
description: input.description ?? "",
|
||||||
data: input.data,
|
data: input.data,
|
||||||
assets: collectExportAssets(input.data),
|
assets: collectExportAssets(input.data),
|
||||||
};
|
};
|
||||||
@@ -72,7 +75,11 @@ export function importCanvasProjectExport(value: unknown) {
|
|||||||
? `${payload.title.trim()} Copy`
|
? `${payload.title.trim()} Copy`
|
||||||
: "导入画布";
|
: "导入画布";
|
||||||
|
|
||||||
return createCanvasProject({ title, data });
|
return createCanvasProject({
|
||||||
|
title,
|
||||||
|
description: payload.description,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectExportAssets(data: CanvasProjectData) {
|
function collectExportAssets(data: CanvasProjectData) {
|
||||||
@@ -130,6 +137,8 @@ function normalizeCanvasProjectExport(value: unknown): CanvasProjectExportV1 | n
|
|||||||
? candidate.exportedAt
|
? candidate.exportedAt
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
title: candidate.title,
|
title: candidate.title,
|
||||||
|
description:
|
||||||
|
typeof candidate.description === "string" ? candidate.description : "",
|
||||||
data,
|
data,
|
||||||
assets,
|
assets,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export async function submitImageJobRequest(request: Request) {
|
|||||||
status: job.status,
|
status: job.status,
|
||||||
phase: job.phase,
|
phase: job.phase,
|
||||||
progressMessage: job.progressMessage,
|
progressMessage: job.progressMessage,
|
||||||
pollUrl: `/api/images?jobId=${job.jobId}`,
|
eventsUrl: `/api/images/events?jobId=${job.jobId}`,
|
||||||
durationMs: elapsedMs(startedAt),
|
durationMs: elapsedMs(startedAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -267,6 +267,93 @@ export function getImageJobResponse(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function streamImageJobEvents(request: Request) {
|
||||||
|
cleanupOldJobs();
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const jobId = url.searchParams.get("jobId");
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
return NextResponse.json({ error: "Missing jobId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = imageJobs.get(jobId);
|
||||||
|
if (!job) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Image job not found or expired",
|
||||||
|
jobId,
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
let closed = false;
|
||||||
|
let lastUpdatedAt = "";
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||||
|
controller.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendJob = () => {
|
||||||
|
if (closed) return;
|
||||||
|
const currentJob = imageJobs.get(jobId);
|
||||||
|
if (!currentJob) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
serializeSseEvent("error", {
|
||||||
|
error: "Image job not found or expired",
|
||||||
|
jobId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentJob.updatedAt === lastUpdatedAt) return;
|
||||||
|
lastUpdatedAt = currentJob.updatedAt;
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(serializeSseEvent("job", serializeJob(currentJob))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentJob.status === "succeeded" ||
|
||||||
|
currentJob.status === "failed"
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sendJob();
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
sendJob();
|
||||||
|
if (!closed) controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
request.signal.addEventListener("abort", close, { once: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"x-request-id": job.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createJob(requestId: string): ImageJob {
|
function createJob(requestId: string): ImageJob {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
@@ -322,7 +409,7 @@ async function runImageJob(
|
|||||||
input.mode === "edit" ? "openai-images-edit" : "openai-images-generate";
|
input.mode === "edit" ? "openai-images-edit" : "openai-images-generate";
|
||||||
updateJob(job, {
|
updateJob(job, {
|
||||||
phase,
|
phase,
|
||||||
progressMessage: "正在调用图片模型,前端会持续轮询结果",
|
progressMessage: "正在调用图片模型,前端会持续接收结果",
|
||||||
});
|
});
|
||||||
log("info", "calling OpenAI image API", {
|
log("info", "calling OpenAI image API", {
|
||||||
jobId: job.jobId,
|
jobId: job.jobId,
|
||||||
@@ -398,7 +485,7 @@ async function runImageJob(
|
|||||||
updateJob(job, {
|
updateJob(job, {
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
phase,
|
phase,
|
||||||
progressMessage: "图片已生成",
|
progressMessage: "生成完成",
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
durationMs: elapsedMs(startedAt),
|
durationMs: elapsedMs(startedAt),
|
||||||
result: resultPayload,
|
result: resultPayload,
|
||||||
@@ -442,6 +529,33 @@ function updateJob(job: ImageJob, patch: Partial<ImageJob>) {
|
|||||||
Object.assign(job, patch, { updatedAt: new Date().toISOString() });
|
Object.assign(job, patch, { updatedAt: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeJob(job: ImageJob) {
|
||||||
|
return {
|
||||||
|
jobId: job.jobId,
|
||||||
|
requestId: job.requestId,
|
||||||
|
status: job.status,
|
||||||
|
phase: job.phase,
|
||||||
|
progressMessage: job.progressMessage,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
updatedAt: job.updatedAt,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
durationMs: job.durationMs,
|
||||||
|
result: job.result,
|
||||||
|
error: job.error?.error,
|
||||||
|
code: job.error?.code,
|
||||||
|
type: job.error?.type,
|
||||||
|
param: job.error?.param,
|
||||||
|
upstreamStatus: job.error?.upstreamStatus,
|
||||||
|
upstreamRequestId: job.error?.upstreamRequestId,
|
||||||
|
details: job.error?.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSseEvent(event: string, data: unknown) {
|
||||||
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
|
async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
|
||||||
const images = formData.getAll("image");
|
const images = formData.getAll("image");
|
||||||
const mask = formData.get("mask");
|
const mask = formData.get("mask");
|
||||||
|
|||||||