feat: add flow board with database drafts
This commit is contained in:
parent
36f7eea13b
commit
d88c28356a
12
app/(chat)/flow-board/layout.tsx
Normal file
12
app/(chat)/flow-board/layout.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export default function FlowBoardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
app/(chat)/flow-board/page.tsx
Normal file
19
app/(chat)/flow-board/page.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
|
||||||
|
import { getSiteSettings } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "流程白板",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function FlowBoardPage() {
|
||||||
|
const siteSettings = await getSiteSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlowBoardClient
|
||||||
|
siteLogo={siteSettings.siteLogo || null}
|
||||||
|
siteLogoDark={siteSettings.siteLogoDark || null}
|
||||||
|
siteName={siteSettings.siteName || "AI Chatbot"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
app/api/flow-board/draft/route.ts
Normal file
90
app/api/flow-board/draft/route.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
getFlowBoardDraftByUserId,
|
||||||
|
upsertFlowBoardDraftByUserId,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import type { FlowBoardSnapshotData } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return isRecord(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFlowBoardSnapshot(value: unknown): FlowBoardSnapshotData {
|
||||||
|
const raw = isRecord(value) ? value : {};
|
||||||
|
const updatedAt =
|
||||||
|
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||||
|
? raw.updatedAt
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
children: Array.isArray(raw.children)
|
||||||
|
? raw.children.filter(isRecord)
|
||||||
|
: [],
|
||||||
|
viewport: normalizeRecord(raw.viewport),
|
||||||
|
theme: normalizeRecord(raw.theme),
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = await getFlowBoardDraftByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
snapshot: draft?.snapshot ?? { children: [] },
|
||||||
|
updatedAt: draft?.updatedAt?.toISOString() ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
snapshot?: unknown;
|
||||||
|
};
|
||||||
|
const snapshot = normalizeFlowBoardSnapshot(body.snapshot ?? body);
|
||||||
|
const savedDraft = await upsertFlowBoardDraftByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!savedDraft) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "保存流程白板失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
snapshot: savedDraft.snapshot,
|
||||||
|
updatedAt: savedDraft.updatedAt?.toISOString() ?? snapshot.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -69,12 +69,13 @@ export function ChatShell({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isLovartHome = pathname === "/" && homeVariant === "lovart";
|
const isLovartHome = pathname === "/" && homeVariant === "lovart";
|
||||||
const isAiCadWorkspace = pathname === "/ai-cad";
|
const isAiCadWorkspace = pathname === "/ai-cad";
|
||||||
|
const isFlowBoardWorkspace = pathname === "/flow-board";
|
||||||
|
|
||||||
if (isLovartHome) {
|
if (isLovartHome) {
|
||||||
return <div className="h-svh w-full overflow-y-auto">{children}</div>;
|
return <div className="h-svh w-full overflow-y-auto">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAiCadWorkspace) {
|
if (isAiCadWorkspace || isFlowBoardWorkspace) {
|
||||||
return <div className="h-svh w-full overflow-hidden">{children}</div>;
|
return <div className="h-svh w-full overflow-hidden">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
268
components/flow-board/drawnix-workspace.tsx
Normal file
268
components/flow-board/drawnix-workspace.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Drawnix, type DrawnixProps } from "@drawnix/drawnix";
|
||||||
|
import type { BoardChangeData } from "@plait-board/react-board";
|
||||||
|
import type {
|
||||||
|
PlaitElement,
|
||||||
|
PlaitTheme,
|
||||||
|
ThemeColorMode,
|
||||||
|
Viewport,
|
||||||
|
} from "@plait/core";
|
||||||
|
import { ChevronLeft, Save } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type FlowBoardWorkspaceProps = {
|
||||||
|
siteName: string;
|
||||||
|
siteLogo: string | null;
|
||||||
|
siteLogoDark: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowBoardSnapshot = {
|
||||||
|
children: PlaitElement[];
|
||||||
|
viewport?: Viewport;
|
||||||
|
theme?: PlaitTheme;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLOW_BOARD_DRAFT_API = "/api/flow-board/draft";
|
||||||
|
|
||||||
|
function normalizeSnapshot(value: unknown): FlowBoardSnapshot {
|
||||||
|
const raw =
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Partial<FlowBoardSnapshot>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
children: Array.isArray(raw.children) ? raw.children : [],
|
||||||
|
viewport: raw.viewport,
|
||||||
|
theme: raw.theme,
|
||||||
|
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSavedAt(value: Date | null) {
|
||||||
|
if (!value) return "数据库自动保存";
|
||||||
|
return `已保存 ${value.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawnixWorkspace({
|
||||||
|
siteName,
|
||||||
|
siteLogo,
|
||||||
|
siteLogoDark,
|
||||||
|
}: FlowBoardWorkspaceProps) {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const [snapshot, setSnapshot] = useState<FlowBoardSnapshot>({
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
|
||||||
|
const snapshotRef = useRef<FlowBoardSnapshot>({ children: [] });
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const displayLogo = useMemo(() => {
|
||||||
|
if (resolvedTheme === "dark" && siteLogoDark) {
|
||||||
|
return siteLogoDark;
|
||||||
|
}
|
||||||
|
return siteLogo || siteLogoDark;
|
||||||
|
}, [resolvedTheme, siteLogo, siteLogoDark]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadDraft = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(FLOW_BOARD_DRAFT_API, {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load flow board draft: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
data?: { snapshot?: unknown; updatedAt?: string | null };
|
||||||
|
};
|
||||||
|
if (!cancelled) {
|
||||||
|
const nextSnapshot = normalizeSnapshot(result.data?.snapshot);
|
||||||
|
snapshotRef.current = nextSnapshot;
|
||||||
|
setSnapshot(nextSnapshot);
|
||||||
|
setLastSavedAt(
|
||||||
|
result.data?.updatedAt
|
||||||
|
? new Date(result.data.updatedAt)
|
||||||
|
: nextSnapshot.updatedAt
|
||||||
|
? new Date(nextSnapshot.updatedAt)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[FlowBoard] Failed to load draft:", error);
|
||||||
|
toast.error("流程白板草稿加载失败");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadDraft();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleSave = useCallback((nextSnapshot: FlowBoardSnapshot) => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(FLOW_BOARD_DRAFT_API, {
|
||||||
|
body: JSON.stringify({ snapshot: nextSnapshot }),
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save flow board draft: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
data?: { updatedAt?: string | null };
|
||||||
|
};
|
||||||
|
setLastSavedAt(
|
||||||
|
result.data?.updatedAt
|
||||||
|
? new Date(result.data.updatedAt)
|
||||||
|
: new Date(nextSnapshot.updatedAt || Date.now())
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[FlowBoard] Failed to save draft:", error);
|
||||||
|
toast.error("流程白板草稿保存到数据库失败");
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSnapshot = useCallback(
|
||||||
|
(partialSnapshot: Partial<FlowBoardSnapshot>) => {
|
||||||
|
const nextSnapshot = normalizeSnapshot({
|
||||||
|
...snapshotRef.current,
|
||||||
|
...partialSnapshot,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
snapshotRef.current = nextSnapshot;
|
||||||
|
setSnapshot(nextSnapshot);
|
||||||
|
scheduleSave(nextSnapshot);
|
||||||
|
},
|
||||||
|
[scheduleSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrawnixChange = useCallback(
|
||||||
|
(value: BoardChangeData) => {
|
||||||
|
const shouldPersist = value.operations.some(
|
||||||
|
(operation) => operation.type !== "set_selection"
|
||||||
|
);
|
||||||
|
if (!shouldPersist) return;
|
||||||
|
|
||||||
|
updateSnapshot({
|
||||||
|
children: value.children,
|
||||||
|
theme: value.theme,
|
||||||
|
viewport: value.viewport,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(children: PlaitElement[]) => {
|
||||||
|
updateSnapshot({ children });
|
||||||
|
},
|
||||||
|
[updateSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewportChange = useCallback(
|
||||||
|
(viewport: Viewport) => {
|
||||||
|
updateSnapshot({ viewport });
|
||||||
|
},
|
||||||
|
[updateSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThemeChange = useCallback(
|
||||||
|
(themeColorMode: ThemeColorMode) => {
|
||||||
|
updateSnapshot({ theme: { themeColorMode } });
|
||||||
|
},
|
||||||
|
[updateSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-svh w-full items-center justify-center bg-[#f7f8fb] text-[#697386]">
|
||||||
|
<div className="rounded-2xl border border-black/10 bg-white px-5 py-3 font-medium text-sm shadow-sm">
|
||||||
|
正在恢复流程白板...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative h-svh w-full overflow-hidden bg-[#f7f8fb]">
|
||||||
|
<div className="pointer-events-none absolute top-1 left-3 z-20 flex h-7 items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
className="pointer-events-auto inline-flex h-7 items-center gap-1 rounded-full border border-black/10 bg-white px-2 font-medium text-[#4f566b] text-xs shadow-sm hover:bg-white"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/";
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pointer-events-auto flex h-7 items-center gap-2 rounded-full border border-black/10 bg-white px-2 shadow-sm">
|
||||||
|
{displayLogo ? (
|
||||||
|
<img
|
||||||
|
alt={siteName}
|
||||||
|
className="h-5 w-5 rounded-full object-cover"
|
||||||
|
src={displayLogo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-[#111827] font-semibold text-[10px] text-white">
|
||||||
|
{siteName.slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-[#111827] text-xs">
|
||||||
|
流程白板
|
||||||
|
</span>
|
||||||
|
<span className="max-w-[140px] truncate text-[#7a8297] text-[10px]">
|
||||||
|
{siteName}
|
||||||
|
</span>
|
||||||
|
<span className="mx-0.5 h-3 w-px bg-black/10" />
|
||||||
|
<Save className="h-3 w-3 text-[#7a8297]" />
|
||||||
|
<span className="text-[#7a8297] text-[10px]">
|
||||||
|
{formatSavedAt(lastSavedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawnix
|
||||||
|
className="h-full w-full"
|
||||||
|
onChange={handleDrawnixChange as NonNullable<DrawnixProps["onChange"]>}
|
||||||
|
onThemeChange={handleThemeChange}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
onViewportChange={handleViewportChange}
|
||||||
|
theme={snapshot.theme}
|
||||||
|
tutorial={false}
|
||||||
|
value={snapshot.children}
|
||||||
|
viewport={snapshot.viewport}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/flow-board/flow-board-client.tsx
Normal file
32
components/flow-board/flow-board-client.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
type FlowBoardClientProps = {
|
||||||
|
siteName: string;
|
||||||
|
siteLogo: string | null;
|
||||||
|
siteLogoDark: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DrawnixWorkspace = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/flow-board/drawnix-workspace").then(
|
||||||
|
(mod) => mod.DrawnixWorkspace
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-svh w-full items-center justify-center bg-[#f7f8fb] text-[#697386]">
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-black/10 bg-white px-5 py-3 shadow-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="font-medium text-sm">正在加载流程白板...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function FlowBoardClient(props: FlowBoardClientProps) {
|
||||||
|
return <DrawnixWorkspace {...props} />;
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
Workflow,
|
||||||
X,
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -1595,6 +1596,19 @@ export function LovartHome({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">视频画布</TooltipContent>
|
<TooltipContent side="right">视频画布</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
||||||
|
onClick={() => router.push("/flow-board")}
|
||||||
|
title="流程白板"
|
||||||
|
>
|
||||||
|
<Workflow className="h-[18px] w-[18px]" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">流程白板</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
{siteSettingsData?.productPlanningEnabled !== false ? (
|
{siteSettingsData?.productPlanningEnabled !== false ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Trophy,
|
Trophy,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
Workflow,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -615,6 +616,18 @@ export function TopNavbar({
|
|||||||
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
|
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={navItemBaseClass}
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/flow-board";
|
||||||
|
}}
|
||||||
|
title="流程白板"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Workflow className="h-5 w-5" />
|
||||||
|
<span className={navLabelClass}>{getShortLabel("流程白板")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 无限画布 */}
|
{/* 无限画布 */}
|
||||||
<button
|
<button
|
||||||
className={navItemBaseClass}
|
className={navItemBaseClass}
|
||||||
|
|||||||
11
lib/db/migrations/0069_add_flow_board_drafts.sql
Normal file
11
lib/db/migrations/0069_add_flow_board_drafts.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "FlowBoardDraft" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
|
||||||
|
"snapshot" jsonb NOT NULL DEFAULT '{"children":[]}'::jsonb,
|
||||||
|
"createdAt" timestamp NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamp NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "FlowBoardDraft_userId_unique" UNIQUE("userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "FlowBoardDraft_user_updated_idx"
|
||||||
|
ON "FlowBoardDraft"("userId", "updatedAt");
|
||||||
@ -386,6 +386,13 @@
|
|||||||
"when": 1775782800000,
|
"when": 1775782800000,
|
||||||
"tag": "0068_add_agent_psd_render_tasks",
|
"tag": "0068_add_agent_psd_render_tasks",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 55,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775869200000,
|
||||||
|
"tag": "0069_add_flow_board_drafts",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,9 @@ import {
|
|||||||
vote,
|
vote,
|
||||||
aiCadWorkspace,
|
aiCadWorkspace,
|
||||||
type AiCadWorkspace,
|
type AiCadWorkspace,
|
||||||
|
flowBoardDraft,
|
||||||
|
type FlowBoardDraft,
|
||||||
|
type FlowBoardSnapshotData,
|
||||||
aiProvider,
|
aiProvider,
|
||||||
type AiProvider,
|
type AiProvider,
|
||||||
aiModel,
|
aiModel,
|
||||||
@ -894,6 +897,59 @@ export async function deleteAiCadWorkspaceById(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFlowBoardDraftByUserId({
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
}): Promise<FlowBoardDraft | null> {
|
||||||
|
try {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(flowBoardDraft)
|
||||||
|
.where(eq(flowBoardDraft.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return result ?? null;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new ChatSDKError(
|
||||||
|
"bad_request:database",
|
||||||
|
"Failed to get flow board draft"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertFlowBoardDraftByUserId(params: {
|
||||||
|
userId: string;
|
||||||
|
snapshot: FlowBoardSnapshotData;
|
||||||
|
}): Promise<FlowBoardDraft | null> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const [result] = await db
|
||||||
|
.insert(flowBoardDraft)
|
||||||
|
.values({
|
||||||
|
userId: params.userId,
|
||||||
|
snapshot: params.snapshot,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: flowBoardDraft.userId,
|
||||||
|
set: {
|
||||||
|
snapshot: params.snapshot,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result ?? null;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new ChatSDKError(
|
||||||
|
"bad_request:database",
|
||||||
|
"Failed to save flow board draft"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function upsertConversationWorkingState(params: {
|
export async function upsertConversationWorkingState(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|||||||
@ -953,6 +953,29 @@ export const aiCadWorkspace = pgTable("AiCadWorkspace", {
|
|||||||
|
|
||||||
export type AiCadWorkspace = InferSelectModel<typeof aiCadWorkspace>;
|
export type AiCadWorkspace = InferSelectModel<typeof aiCadWorkspace>;
|
||||||
|
|
||||||
|
export type FlowBoardSnapshotData = {
|
||||||
|
children: Record<string, unknown>[];
|
||||||
|
viewport?: Record<string, unknown> | null;
|
||||||
|
theme?: Record<string, unknown> | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const flowBoardDraft = pgTable("FlowBoardDraft", {
|
||||||
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
|
userId: uuid("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" })
|
||||||
|
.unique(),
|
||||||
|
snapshot: jsonb("snapshot")
|
||||||
|
.$type<FlowBoardSnapshotData>()
|
||||||
|
.notNull()
|
||||||
|
.default({ children: [] }),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FlowBoardDraft = InferSelectModel<typeof flowBoardDraft>;
|
||||||
|
|
||||||
// 每轮消息的目标资产/图片定位审计表
|
// 每轮消息的目标资产/图片定位审计表
|
||||||
export const messageTargetResolution = pgTable("MessageTargetResolution", {
|
export const messageTargetResolution = pgTable("MessageTargetResolution", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
|
|||||||
14
package.json
14
package.json
@ -32,12 +32,21 @@
|
|||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@codemirror/view": "^6.35.3",
|
"@codemirror/view": "^6.35.3",
|
||||||
|
"@drawnix/drawnix": "0.4.0-2",
|
||||||
"@fal-ai/serverless-client": "^0.15.0",
|
"@fal-ai/serverless-client": "^0.15.0",
|
||||||
"@icons-pack/react-simple-icons": "^13.7.0",
|
"@icons-pack/react-simple-icons": "^13.7.0",
|
||||||
"@langchain/core": "^1.1.19",
|
"@langchain/core": "^1.1.19",
|
||||||
"@langchain/langgraph": "^1.1.3",
|
"@langchain/langgraph": "^1.1.3",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/api-logs": "^0.200.0",
|
"@opentelemetry/api-logs": "^0.200.0",
|
||||||
|
"@plait-board/react-board": "0.4.0-2",
|
||||||
|
"@plait-board/react-text": "0.4.0-2",
|
||||||
|
"@plait/common": "^0.92.1",
|
||||||
|
"@plait/core": "^0.92.1",
|
||||||
|
"@plait/draw": "^0.92.1",
|
||||||
|
"@plait/layouts": "^0.92.1",
|
||||||
|
"@plait/mind": "^0.92.1",
|
||||||
|
"@plait/text-plugins": "^0.92.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
@ -75,6 +84,7 @@
|
|||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
|
"is-hotkey": "^0.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"katex": "^0.16.25",
|
"katex": "^0.16.25",
|
||||||
"lucide-react": "^0.446.0",
|
"lucide-react": "^0.446.0",
|
||||||
@ -107,6 +117,10 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"shiki": "^3.12.2",
|
"shiki": "^3.12.2",
|
||||||
|
"slate": "^0.124.0",
|
||||||
|
"slate-dom": "^0.124.0",
|
||||||
|
"slate-history": "^0.115.0",
|
||||||
|
"slate-react": "^0.124.0",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"streamdown": "^1.3.0",
|
"streamdown": "^1.3.0",
|
||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
|
|||||||
750
pnpm-lock.yaml
generated
750
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
public/vendor/drawnix.css
vendored
Normal file
4
public/vendor/drawnix.css
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user