feat: add AI-CAD workspace and KittyCAD integration
This commit is contained in:
parent
276da822a6
commit
413b5ffac1
@ -43,3 +43,8 @@ GTP5_MODEL=gpt-5-nano
|
||||
# 优先使用系统设置-本地AI里的 `tripo-3d` 提供商;若未配置则回退到环境变量
|
||||
TRIPO_API_KEY=your-tripo-api-key
|
||||
TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi
|
||||
|
||||
# KittyCAD / Zoo Text-to-CAD(可选)
|
||||
# 优先使用系统设置-本地AI里的 `kittycad-cad` 提供商;若未配置则回退到环境变量
|
||||
KITTYCAD_API_TOKEN=your-kittycad-api-token
|
||||
KITTYCAD_BASE_URL=https://api.zoo.dev
|
||||
|
||||
@ -331,6 +331,7 @@ type AiProvider = {
|
||||
| "zhipu"
|
||||
| "tripo-3d"
|
||||
| "worldlabs-3d"
|
||||
| "kittycad-cad"
|
||||
| "runway"
|
||||
| "volcano-multimodal";
|
||||
baseUrl: string;
|
||||
@ -655,6 +656,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "midjourney", label: "Midjourney" },
|
||||
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
|
||||
{ value: "worldlabs-3d", label: "World Labs 3D 世界生成" },
|
||||
{ value: "kittycad-cad", label: "KittyCAD / Zoo CAD" },
|
||||
{ value: "runway", label: "Runway API" },
|
||||
{ value: "zhipu", label: "智普官方 4.7" },
|
||||
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
||||
@ -683,6 +685,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
"fal-video": "https://fal.run",
|
||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||
"worldlabs-3d": "https://api.worldlabs.ai",
|
||||
"kittycad-cad": "https://api.zoo.dev",
|
||||
runway: "https://api.dev.runwayml.com",
|
||||
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
||||
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
@ -1371,6 +1374,14 @@ export function AdminSettingsClient() {
|
||||
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [kittycadCadConfig, setKittycadCadConfig] = useState({
|
||||
providerId: "",
|
||||
name: "KittyCAD CAD",
|
||||
baseUrl: getProviderDefaultBaseUrl("kittycad-cad"),
|
||||
apiKey: "",
|
||||
isEnabled: true,
|
||||
});
|
||||
const [isTestingKittycadCad, setIsTestingKittycadCad] = useState(false);
|
||||
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
|
||||
providerId: "",
|
||||
modelEntryId: "",
|
||||
@ -1902,6 +1913,32 @@ export function AdminSettingsClient() {
|
||||
[worldlabsProviders]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const kittycadProvider = (providersData?.providers || []).find(
|
||||
(provider) => provider.type === "kittycad-cad"
|
||||
);
|
||||
if (kittycadProvider) {
|
||||
setKittycadCadConfig({
|
||||
providerId: kittycadProvider.id,
|
||||
name: kittycadProvider.name || "KittyCAD CAD",
|
||||
baseUrl:
|
||||
kittycadProvider.baseUrl ||
|
||||
getProviderDefaultBaseUrl("kittycad-cad"),
|
||||
apiKey: "",
|
||||
isEnabled: kittycadProvider.isEnabled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setKittycadCadConfig({
|
||||
providerId: "",
|
||||
name: "KittyCAD CAD",
|
||||
baseUrl: getProviderDefaultBaseUrl("kittycad-cad"),
|
||||
apiKey: "",
|
||||
isEnabled: true,
|
||||
});
|
||||
}, [providersData?.providers]);
|
||||
|
||||
// 导航链接状态
|
||||
const [navLinkForm, setNavLinkForm] = useState({
|
||||
id: "",
|
||||
@ -2217,6 +2254,7 @@ export function AdminSettingsClient() {
|
||||
| "zhipu"
|
||||
| "tripo-3d"
|
||||
| "worldlabs-3d"
|
||||
| "kittycad-cad"
|
||||
| "runway"
|
||||
| "volcano-multimodal",
|
||||
baseUrl: "",
|
||||
@ -4248,6 +4286,98 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKittycadCadConfig = async () => {
|
||||
try {
|
||||
const trimmedName = kittycadCadConfig.name.trim() || "KittyCAD CAD";
|
||||
const trimmedBaseUrl =
|
||||
kittycadCadConfig.baseUrl.trim() ||
|
||||
getProviderDefaultBaseUrl("kittycad-cad");
|
||||
const hasProvider = Boolean(kittycadCadConfig.providerId);
|
||||
|
||||
if (!hasProvider && !kittycadCadConfig.apiKey.trim()) {
|
||||
toast.error("请填写 KittyCAD API Token");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData: Record<string, any> = hasProvider
|
||||
? {
|
||||
id: kittycadCadConfig.providerId,
|
||||
name: trimmedName,
|
||||
type: "kittycad-cad",
|
||||
baseUrl: trimmedBaseUrl,
|
||||
isEnabled: kittycadCadConfig.isEnabled,
|
||||
}
|
||||
: {
|
||||
name: trimmedName,
|
||||
type: "kittycad-cad",
|
||||
baseUrl: trimmedBaseUrl,
|
||||
apiKey: kittycadCadConfig.apiKey.trim(),
|
||||
isEnabled: kittycadCadConfig.isEnabled,
|
||||
};
|
||||
|
||||
if (hasProvider && kittycadCadConfig.apiKey.trim()) {
|
||||
requestData.apiKey = kittycadCadConfig.apiKey.trim();
|
||||
}
|
||||
|
||||
const response = await fetch("/api/admin/ai-providers", {
|
||||
method: hasProvider ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("保存失败");
|
||||
}
|
||||
|
||||
toast.success("KittyCAD CAD 配置保存成功");
|
||||
setKittycadCadConfig((prev) => ({ ...prev, apiKey: "" }));
|
||||
mutateProviders();
|
||||
} catch (error) {
|
||||
console.error("保存 KittyCAD CAD 配置失败:", error);
|
||||
toast.error("保存 KittyCAD CAD 配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestKittycadCadConfig = async () => {
|
||||
try {
|
||||
setIsTestingKittycadCad(true);
|
||||
|
||||
const response = await fetch("/api/admin/kittycad/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
providerId: kittycadCadConfig.providerId || undefined,
|
||||
name: kittycadCadConfig.name.trim() || "KittyCAD CAD",
|
||||
baseUrl:
|
||||
kittycadCadConfig.baseUrl.trim() ||
|
||||
getProviderDefaultBaseUrl("kittycad-cad"),
|
||||
apiKey: kittycadCadConfig.apiKey.trim() || undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload?.cause || payload?.message || payload?.error || "连接测试失败"
|
||||
);
|
||||
}
|
||||
|
||||
const accountLabel =
|
||||
payload?.userName || payload?.userEmail || payload?.userId || "当前账号";
|
||||
toast.success(
|
||||
`连接成功:${accountLabel}(${payload?.latencyMs || 0}ms)`
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "KittyCAD 连接测试失败"
|
||||
);
|
||||
} finally {
|
||||
setIsTestingKittycadCad(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMoonshotDetailAgentConfig = async () => {
|
||||
try {
|
||||
const trimmedBaseUrl =
|
||||
@ -6430,6 +6560,7 @@ export function AdminSettingsClient() {
|
||||
midjourney: "bg-fuchsia-600",
|
||||
"tripo-3d": "bg-teal-500",
|
||||
"worldlabs-3d": "bg-sky-600",
|
||||
"kittycad-cad": "bg-orange-500",
|
||||
openai: "bg-green-500",
|
||||
zhipu: "bg-lime-500",
|
||||
"volcano-multimodal": "bg-fuchsia-500",
|
||||
@ -12716,6 +12847,131 @@ export function AdminSettingsClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-xl">
|
||||
KittyCAD CAD API 配置
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
|
||||
服务,保存后写入数据库 AI 提供商配置。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<LocalAiSectionToggleButton
|
||||
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
||||
onClick={() => toggleLocalAiSection("kittycad-cad")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"local-ai-config-card-content space-y-6 pt-6",
|
||||
!isLocalAiSectionExpanded("kittycad-cad") && "hidden"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kittycadCadName">提供商名称</Label>
|
||||
<Input
|
||||
id="kittycadCadName"
|
||||
value={kittycadCadConfig.name}
|
||||
onChange={(e) =>
|
||||
setKittycadCadConfig({
|
||||
...kittycadCadConfig,
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="KittyCAD CAD"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kittycadCadBaseUrl">API 地址</Label>
|
||||
<Input
|
||||
id="kittycadCadBaseUrl"
|
||||
value={kittycadCadConfig.baseUrl}
|
||||
onChange={(e) =>
|
||||
setKittycadCadConfig({
|
||||
...kittycadCadConfig,
|
||||
baseUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={getProviderDefaultBaseUrl("kittycad-cad")}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
默认:{getProviderDefaultBaseUrl("kittycad-cad")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kittycadCadApiKey">
|
||||
API Token {kittycadCadConfig.providerId ? "(留空则不修改)" : "*"}
|
||||
</Label>
|
||||
<Input
|
||||
id="kittycadCadApiKey"
|
||||
type="password"
|
||||
value={kittycadCadConfig.apiKey}
|
||||
onChange={(e) =>
|
||||
setKittycadCadConfig({
|
||||
...kittycadCadConfig,
|
||||
apiKey: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
kittycadCadConfig.providerId
|
||||
? "留空则不修改当前 Token"
|
||||
: "请输入 KittyCAD / Zoo API Token"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={kittycadCadConfig.isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setKittycadCadConfig({
|
||||
...kittycadCadConfig,
|
||||
isEnabled: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
|
||||
{kittycadCadConfig.isEnabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg border border-orange-200 bg-orange-50 p-4 text-orange-800 text-xs">
|
||||
<p>• 默认接入地址为 `https://api.zoo.dev`。</p>
|
||||
<p>
|
||||
• 建议填写 Zoo 官方 API Token;如果你有自建兼容服务,也可以改成自定义地址。
|
||||
</p>
|
||||
<p>
|
||||
• 测试连接只会校验当前账号和 Token,不会创建 CAD 任务,也不会消耗生成额度。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestKittycadCadConfig}
|
||||
disabled={isTestingKittycadCad}
|
||||
>
|
||||
{isTestingKittycadCad ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
测试连接
|
||||
</Button>
|
||||
<Button onClick={handleSaveKittycadCadConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存 KittyCAD CAD 配置
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 抠图API配置 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
|
||||
5
app/(chat)/ai-cad/page.tsx
Normal file
5
app/(chat)/ai-cad/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
|
||||
|
||||
export default function AiCadPage() {
|
||||
return <AiCadWorkbench />;
|
||||
}
|
||||
@ -31,6 +31,7 @@ const providerTypes = [
|
||||
"zhipu",
|
||||
"tripo-3d",
|
||||
"worldlabs-3d",
|
||||
"kittycad-cad",
|
||||
"runway",
|
||||
"volcano-multimodal",
|
||||
] as const;
|
||||
@ -64,6 +65,7 @@ const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
|
||||
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
||||
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
||||
const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/;
|
||||
|
||||
@ -212,6 +214,8 @@ export async function POST(request: Request) {
|
||||
? data.baseUrl || TRIPO_3D_BASE_URL
|
||||
: data.type === "worldlabs-3d"
|
||||
? data.baseUrl || WORLDLABS_3D_BASE_URL
|
||||
: data.type === "kittycad-cad"
|
||||
? data.baseUrl || KITTYCAD_CAD_BASE_URL
|
||||
: data.type === "runway"
|
||||
? data.baseUrl || RUNWAY_BASE_URL
|
||||
: data.baseUrl,
|
||||
|
||||
148
app/api/admin/kittycad/test/route.ts
Normal file
148
app/api/admin/kittycad/test/route.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { z } from "zod";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const requestSchema = z.object({
|
||||
config: z
|
||||
.object({
|
||||
providerId: z.string().uuid().optional(),
|
||||
name: z.string().trim().optional(),
|
||||
baseUrl: z.string().trim().optional(),
|
||||
apiKey: z.string().trim().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function pickDisplayName(user: Record<string, unknown>) {
|
||||
const candidates = [
|
||||
user.name,
|
||||
user.full_name,
|
||||
user.display_name,
|
||||
user.username,
|
||||
user.first_name,
|
||||
];
|
||||
|
||||
for (const value of candidates) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickEmail(user: Record<string, unknown>) {
|
||||
const candidates = [user.email, user.primary_email];
|
||||
|
||||
for (const value of candidates) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveKittyCadConfig(
|
||||
input?: {
|
||||
providerId?: string;
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
} | null
|
||||
) {
|
||||
const providerId = String(input?.providerId || "").trim();
|
||||
const inputApiKey = String(input?.apiKey || "").trim();
|
||||
const inputBaseUrl = String(input?.baseUrl || "").trim();
|
||||
const inputName = String(input?.name || "").trim();
|
||||
|
||||
if (providerId) {
|
||||
const savedProvider = await getAiProviderById({ id: providerId });
|
||||
if (!savedProvider) {
|
||||
throw new Error("未找到对应的 KittyCAD CAD 提供商");
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: inputApiKey || String(savedProvider.apiKey || "").trim(),
|
||||
baseUrl: inputBaseUrl || String(savedProvider.baseUrl || "").trim(),
|
||||
providerId: savedProvider.id,
|
||||
providerName: inputName || savedProvider.name || "KittyCAD CAD",
|
||||
source: "provider" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (inputApiKey) {
|
||||
return {
|
||||
apiKey: inputApiKey,
|
||||
baseUrl: inputBaseUrl || "https://api.zoo.dev",
|
||||
providerName: inputName || "KittyCAD CAD",
|
||||
source: "provider" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return await getKittyCadProviderConfig();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = requestSchema.parse(await request.json().catch(() => ({})));
|
||||
const config = await resolveKittyCadConfig(body.config);
|
||||
|
||||
if (!config.apiKey.trim()) {
|
||||
throw new Error("请填写 KittyCAD API Token");
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const user = await kittyCadRequest<Record<string, unknown>>(config, {
|
||||
method: "GET",
|
||||
path: "/user",
|
||||
});
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
providerName: config.providerName || "KittyCAD CAD",
|
||||
baseUrl: config.baseUrl,
|
||||
latencyMs,
|
||||
userId:
|
||||
typeof user.id === "string" && user.id.trim() ? user.id.trim() : null,
|
||||
userName: pickDisplayName(user),
|
||||
userEmail: pickEmail(user),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
|
||||
if (error instanceof KittyCadApiError) {
|
||||
return Response.json(
|
||||
{
|
||||
code: "bad_request:api",
|
||||
message: error.message,
|
||||
payload: error.payload,
|
||||
},
|
||||
{ status: error.httpStatus || 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: "bad_request:api",
|
||||
message:
|
||||
error instanceof Error ? error.message : "KittyCAD 连接测试失败",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
app/api/ai-cad/provider/route.ts
Normal file
27
app/api/ai-cad/provider/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await findKittyCadProviderConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
configured: Boolean(config),
|
||||
source: config?.source || "none",
|
||||
providerName: config?.providerName || null,
|
||||
baseUrl: config?.baseUrl || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
391
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
391
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,391 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type KittyCadTaskResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
outputs?: Record<string, string> | null;
|
||||
error?: string | null;
|
||||
code?: string | null;
|
||||
prompt?: string | null;
|
||||
conversation_id?: string | null;
|
||||
model_version?: string | null;
|
||||
output_format?: string | null;
|
||||
};
|
||||
|
||||
type ArchivedFile = {
|
||||
sourceHash: string;
|
||||
archivedUrl: string;
|
||||
};
|
||||
|
||||
type RouteParams = {
|
||||
params: Promise<{ taskId: string }>;
|
||||
};
|
||||
|
||||
function getArchiveCache() {
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
if (!globalState.__kittyCadAssetArchiveCache) {
|
||||
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
|
||||
}
|
||||
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
|
||||
}
|
||||
|
||||
function getArchiveInflight() {
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
if (!globalState.__kittyCadAssetArchiveInflight) {
|
||||
globalState.__kittyCadAssetArchiveInflight = new Map<
|
||||
string,
|
||||
Promise<string | null>
|
||||
>();
|
||||
}
|
||||
return globalState.__kittyCadAssetArchiveInflight as Map<
|
||||
string,
|
||||
Promise<string | null>
|
||||
>;
|
||||
}
|
||||
|
||||
function mapKittyCadStatus(status: unknown) {
|
||||
const normalized = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "queued" ||
|
||||
normalized === "pending" ||
|
||||
normalized === "created" ||
|
||||
normalized === "submitted"
|
||||
) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "running" ||
|
||||
normalized === "processing" ||
|
||||
normalized === "in_progress" ||
|
||||
normalized === "working" ||
|
||||
normalized === "generating"
|
||||
) {
|
||||
return "running" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "completed" ||
|
||||
normalized === "success" ||
|
||||
normalized === "succeeded" ||
|
||||
normalized === "done" ||
|
||||
normalized === "finished"
|
||||
) {
|
||||
return "success" as const;
|
||||
}
|
||||
if (normalized === "cancelled" || normalized === "canceled") {
|
||||
return "cancelled" as const;
|
||||
}
|
||||
if (normalized === "expired" || normalized === "timed_out") {
|
||||
return "expired" as const;
|
||||
}
|
||||
if (normalized === "banned" || normalized === "blocked") {
|
||||
return "banned" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "failed" ||
|
||||
normalized === "error" ||
|
||||
normalized === "errored"
|
||||
) {
|
||||
return "failed" as const;
|
||||
}
|
||||
return "unknown" as const;
|
||||
}
|
||||
|
||||
function getFileExtension(filename: string) {
|
||||
const normalized = String(filename || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||
const segments = cleanName.split(".");
|
||||
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||
}
|
||||
|
||||
function isImageExtension(extension: string) {
|
||||
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
|
||||
}
|
||||
|
||||
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
|
||||
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
|
||||
for (const extension of preferredOrder) {
|
||||
const matched = files.find(
|
||||
(item) => getFileExtension(item.name) === extension
|
||||
);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
|
||||
return (
|
||||
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function archiveOutputIfNeeded(params: {
|
||||
taskId: string;
|
||||
filename: string;
|
||||
base64: string;
|
||||
}) {
|
||||
const normalizedFilename = String(params.filename || "").trim();
|
||||
const normalizedBase64 = String(params.base64 || "").trim();
|
||||
if (!normalizedFilename || !normalizedBase64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||
const cache = getArchiveCache();
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.sourceHash === normalizedBase64) {
|
||||
return cached.archivedUrl;
|
||||
}
|
||||
|
||||
const inflight = getArchiveInflight();
|
||||
const existingTask = inflight.get(cacheKey);
|
||||
if (existingTask) {
|
||||
return (await existingTask) || null;
|
||||
}
|
||||
|
||||
const archiveTask = (async () => {
|
||||
try {
|
||||
const binary = Buffer.from(normalizedBase64, "base64");
|
||||
if (binary.byteLength <= 0) {
|
||||
throw new Error("KittyCAD 输出文件为空");
|
||||
}
|
||||
const arrayBuffer = Uint8Array.from(binary).buffer;
|
||||
|
||||
const safeFilename =
|
||||
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
|
||||
const uploaded = await uploadFile(
|
||||
`kittycad-${params.taskId}-${safeFilename}`,
|
||||
arrayBuffer
|
||||
);
|
||||
cache.set(cacheKey, {
|
||||
sourceHash: normalizedBase64,
|
||||
archivedUrl: uploaded.url,
|
||||
});
|
||||
return uploaded.url;
|
||||
} catch (error) {
|
||||
console.error("[KittyCADArchive] failed", {
|
||||
taskId: params.taskId,
|
||||
filename: normalizedFilename,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(cacheKey, archiveTask);
|
||||
return await archiveTask;
|
||||
}
|
||||
|
||||
async function archiveTextOutputIfNeeded(params: {
|
||||
taskId: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}) {
|
||||
const normalizedFilename = String(params.filename || "").trim();
|
||||
const normalizedContent = String(params.content || "");
|
||||
if (!normalizedFilename || !normalizedContent.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||
const cache = getArchiveCache();
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.sourceHash === normalizedContent) {
|
||||
return cached.archivedUrl;
|
||||
}
|
||||
|
||||
const inflight = getArchiveInflight();
|
||||
const existingTask = inflight.get(cacheKey);
|
||||
if (existingTask) {
|
||||
return (await existingTask) || null;
|
||||
}
|
||||
|
||||
const archiveTask = (async () => {
|
||||
try {
|
||||
const arrayBuffer = Uint8Array.from(
|
||||
new TextEncoder().encode(normalizedContent)
|
||||
).buffer;
|
||||
const safeFilename =
|
||||
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
|
||||
const uploaded = await uploadFile(
|
||||
`kittycad-${params.taskId}-${safeFilename}`,
|
||||
arrayBuffer
|
||||
);
|
||||
cache.set(cacheKey, {
|
||||
sourceHash: normalizedContent,
|
||||
archivedUrl: uploaded.url,
|
||||
});
|
||||
return uploaded.url;
|
||||
} catch (error) {
|
||||
console.error("[KittyCADArchive:text] failed", {
|
||||
taskId: params.taskId,
|
||||
filename: normalizedFilename,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(cacheKey, archiveTask);
|
||||
return await archiveTask;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { taskId } = await params;
|
||||
const normalizedTaskId = String(taskId || "").trim();
|
||||
if (!normalizedTaskId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 taskId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getKittyCadProviderConfig();
|
||||
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
|
||||
method: "GET",
|
||||
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
|
||||
});
|
||||
|
||||
const status = mapKittyCadStatus(result.status);
|
||||
if (status !== "success") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "cad",
|
||||
status,
|
||||
progress: status === "running" ? 65 : null,
|
||||
errorMessage: result.error || null,
|
||||
conversationId: result.conversation_id || null,
|
||||
modelVersion: result.model_version || null,
|
||||
outputFormat: result.output_format || null,
|
||||
output: result,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const outputEntries = Object.entries(result.outputs || {}).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" &&
|
||||
entry[0].trim().length > 0 &&
|
||||
typeof entry[1] === "string" &&
|
||||
entry[1].trim().length > 0
|
||||
);
|
||||
|
||||
const archivedEntries = await Promise.all(
|
||||
outputEntries.map(async ([name, base64]) => {
|
||||
const archivedUrl = await archiveOutputIfNeeded({
|
||||
taskId: normalizedTaskId,
|
||||
filename: name,
|
||||
base64,
|
||||
});
|
||||
return archivedUrl ? { name, url: archivedUrl } : null;
|
||||
})
|
||||
);
|
||||
|
||||
const archivedFiles = archivedEntries.filter(
|
||||
(item): item is { name: string; url: string } =>
|
||||
Boolean(item?.name && item?.url)
|
||||
);
|
||||
|
||||
const hasArchivedKclFile = archivedFiles.some(
|
||||
(item) => getFileExtension(item.name) === "kcl"
|
||||
);
|
||||
if (
|
||||
!hasArchivedKclFile &&
|
||||
typeof result.code === "string" &&
|
||||
result.code.trim()
|
||||
) {
|
||||
const archivedCodeUrl = await archiveTextOutputIfNeeded({
|
||||
taskId: normalizedTaskId,
|
||||
filename: "main.kcl",
|
||||
content: result.code,
|
||||
});
|
||||
if (archivedCodeUrl) {
|
||||
archivedFiles.push({
|
||||
name: "main.kcl",
|
||||
url: archivedCodeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modelFile = pickPreferredModelFile(archivedFiles);
|
||||
const previewImageFile = pickPreviewImageFile(archivedFiles);
|
||||
const downloadUrls = Object.fromEntries(
|
||||
archivedFiles.map((item) => [item.name, item.url])
|
||||
);
|
||||
const codeFile =
|
||||
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
|
||||
null;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "cad",
|
||||
status: "success",
|
||||
progress: 100,
|
||||
modelUrl:
|
||||
modelFile &&
|
||||
["glb", "gltf"].includes(getFileExtension(modelFile.name))
|
||||
? modelFile.url
|
||||
: null,
|
||||
previewImageUrl: previewImageFile?.url || null,
|
||||
errorMessage: result.error || null,
|
||||
conversationId: result.conversation_id || null,
|
||||
modelVersion: result.model_version || null,
|
||||
outputFormat: result.output_format || null,
|
||||
codeUrl: codeFile?.url || null,
|
||||
downloadUrls,
|
||||
output: result,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof KittyCadApiError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
payload: error.payload,
|
||||
},
|
||||
{ status: error.httpStatus || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
309
app/api/ai-cad/tasks/route.ts
Normal file
309
app/api/ai-cad/tasks/route.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
consumeUserPoints,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type CreateAiCadTaskRequest = {
|
||||
prompt?: string;
|
||||
outputFormat?: string;
|
||||
projectName?: string;
|
||||
iterationSourceTaskId?: string;
|
||||
};
|
||||
|
||||
type KittyCadCreateResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
conversation_id?: string | null;
|
||||
model_version?: string | null;
|
||||
output_format?: string | null;
|
||||
};
|
||||
|
||||
type KittyCadSourceTaskResponse = {
|
||||
code?: string | null;
|
||||
outputs?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
type KittyCadIterationResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
conversation_id?: string | null;
|
||||
model_version?: string | null;
|
||||
output_format?: string | null;
|
||||
};
|
||||
|
||||
function getFileExtension(filename: string) {
|
||||
const normalized = String(filename || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||
const segments = cleanName.split(".");
|
||||
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||
}
|
||||
|
||||
function decodeBase64Text(value: unknown) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
|
||||
return decoded || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
|
||||
const directCode = String(task.code || "").trim();
|
||||
if (directCode) {
|
||||
return directCode;
|
||||
}
|
||||
|
||||
const kclOutputEntry = Object.entries(task.outputs || {}).find(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" &&
|
||||
entry[0].trim().length > 0 &&
|
||||
getFileExtension(entry[0]) === "kcl" &&
|
||||
typeof entry[1] === "string" &&
|
||||
entry[1].trim().length > 0
|
||||
);
|
||||
|
||||
if (!kclOutputEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeBase64Text(kclOutputEntry[1]);
|
||||
}
|
||||
|
||||
function mapKittyCadStatus(status: unknown) {
|
||||
const normalized = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "queued" ||
|
||||
normalized === "pending" ||
|
||||
normalized === "created" ||
|
||||
normalized === "submitted"
|
||||
) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "running" ||
|
||||
normalized === "processing" ||
|
||||
normalized === "in_progress" ||
|
||||
normalized === "working" ||
|
||||
normalized === "generating"
|
||||
) {
|
||||
return "running" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "completed" ||
|
||||
normalized === "success" ||
|
||||
normalized === "succeeded" ||
|
||||
normalized === "done" ||
|
||||
normalized === "finished"
|
||||
) {
|
||||
return "success" as const;
|
||||
}
|
||||
if (normalized === "cancelled" || normalized === "canceled") {
|
||||
return "cancelled" as const;
|
||||
}
|
||||
if (normalized === "expired" || normalized === "timed_out") {
|
||||
return "expired" as const;
|
||||
}
|
||||
if (normalized === "banned" || normalized === "blocked") {
|
||||
return "banned" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "failed" ||
|
||||
normalized === "error" ||
|
||||
normalized === "errored"
|
||||
) {
|
||||
return "failed" as const;
|
||||
}
|
||||
return "unknown" as const;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as CreateAiCadTaskRequest;
|
||||
const prompt = String(body.prompt || "").trim();
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入 CAD 描述" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
|
||||
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
|
||||
: 0;
|
||||
|
||||
if (
|
||||
pointsConfig.enabled &&
|
||||
unitCost > 0 &&
|
||||
pointsConfig.blockOnInsufficient !== false
|
||||
) {
|
||||
const userData = await getUserById({ id: session.user.id });
|
||||
if (!userData?.isHighVip) {
|
||||
const currentBalance = Math.max(
|
||||
0,
|
||||
Number(userData?.pointsBalance || 0)
|
||||
);
|
||||
if (currentBalance < unitCost) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "积分不足",
|
||||
code: "insufficient_points",
|
||||
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
|
||||
balance: currentBalance,
|
||||
expectedCost: unitCost,
|
||||
unitCost,
|
||||
},
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outputFormat =
|
||||
String(body.outputFormat || "glb")
|
||||
.trim()
|
||||
.toLowerCase() || "glb";
|
||||
const normalizedProjectName = String(body.projectName || "").trim();
|
||||
const iterationSourceTaskId = String(
|
||||
body.iterationSourceTaskId || ""
|
||||
).trim();
|
||||
const config = await getKittyCadProviderConfig();
|
||||
const generationMode = iterationSourceTaskId ? "iterate" : "create";
|
||||
const result = iterationSourceTaskId
|
||||
? await (async () => {
|
||||
const sourceTask = await kittyCadRequest<KittyCadSourceTaskResponse>(
|
||||
config,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
|
||||
}
|
||||
);
|
||||
const originalSourceCode = extractSourceCodeFromTask(sourceTask);
|
||||
|
||||
if (!originalSourceCode) {
|
||||
throw new KittyCadApiError({
|
||||
message:
|
||||
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
|
||||
httpStatus: 409,
|
||||
});
|
||||
}
|
||||
|
||||
return kittyCadRequest<KittyCadIterationResponse>(config, {
|
||||
method: "POST",
|
||||
path: "/ml/text-to-cad/iteration",
|
||||
json: {
|
||||
original_source_code: originalSourceCode,
|
||||
prompt,
|
||||
source_ranges: [],
|
||||
...(normalizedProjectName
|
||||
? { project_name: normalizedProjectName }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
})()
|
||||
: await kittyCadRequest<KittyCadCreateResponse>(config, {
|
||||
method: "POST",
|
||||
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
|
||||
json: {
|
||||
prompt,
|
||||
...(normalizedProjectName
|
||||
? { project_name: normalizedProjectName }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
const taskId = String(result.id || "").trim();
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "KittyCAD 返回缺少任务 ID",
|
||||
raw: result,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pointsConfig.enabled && unitCost > 0) {
|
||||
try {
|
||||
await consumeUserPoints({
|
||||
userId: session.user.id,
|
||||
amount: unitCost,
|
||||
reason: "model3d_generation",
|
||||
modelId: "kittycad-cad",
|
||||
note:
|
||||
generationMode === "iterate"
|
||||
? "AI-CAD 连续迭代消耗积分"
|
||||
: "AI-CAD 生成消耗积分",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Points] Failed to consume AI-CAD points:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
status: mapKittyCadStatus(result.status),
|
||||
conversationId: result.conversation_id || null,
|
||||
modelVersion: result.model_version || null,
|
||||
outputFormat:
|
||||
generationMode === "iterate"
|
||||
? result.output_format || null
|
||||
: result.output_format || outputFormat,
|
||||
generationMode,
|
||||
sourceTaskId: iterationSourceTaskId || null,
|
||||
raw: result,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof KittyCadApiError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
payload: error.payload,
|
||||
},
|
||||
{ status: error.httpStatus || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
187
app/api/ai-cad/workspace-utils.ts
Normal file
187
app/api/ai-cad/workspace-utils.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import type { AiCadWorkspace } from "@/lib/db/schema";
|
||||
|
||||
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
|
||||
|
||||
const FINAL_TASK_STATUS = new Set([
|
||||
"success",
|
||||
"failed",
|
||||
"banned",
|
||||
"expired",
|
||||
"cancelled",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
const DEFAULT_VIEWER_SETTINGS = {
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1.1,
|
||||
autoRotate: true,
|
||||
showGrid: true,
|
||||
wireframe: false,
|
||||
} as const;
|
||||
|
||||
type WorkspaceVersionLike = {
|
||||
status?: unknown;
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
export type UpdateAiCadWorkspaceRequest = {
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
generationProvider?: string;
|
||||
modelVersion?: string;
|
||||
viewerSettings?: unknown;
|
||||
messages?: unknown;
|
||||
versions?: unknown;
|
||||
activeVersionId?: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
|
||||
}
|
||||
|
||||
export function normalizeViewerSettings(
|
||||
value: unknown
|
||||
): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_VIEWER_SETTINGS };
|
||||
}
|
||||
|
||||
return {
|
||||
envPreset:
|
||||
typeof value.envPreset === "string" && value.envPreset.trim()
|
||||
? value.envPreset
|
||||
: DEFAULT_VIEWER_SETTINGS.envPreset,
|
||||
lightIntensity:
|
||||
typeof value.lightIntensity === "number" &&
|
||||
Number.isFinite(value.lightIntensity)
|
||||
? value.lightIntensity
|
||||
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
|
||||
autoRotate:
|
||||
typeof value.autoRotate === "boolean"
|
||||
? value.autoRotate
|
||||
: DEFAULT_VIEWER_SETTINGS.autoRotate,
|
||||
showGrid:
|
||||
typeof value.showGrid === "boolean"
|
||||
? value.showGrid
|
||||
: DEFAULT_VIEWER_SETTINGS.showGrid,
|
||||
wireframe:
|
||||
typeof value.wireframe === "boolean"
|
||||
? value.wireframe
|
||||
: DEFAULT_VIEWER_SETTINGS.wireframe,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeJsonRecordArray(
|
||||
value: unknown
|
||||
): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is Record<string, unknown> =>
|
||||
isRecord(item)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceName(
|
||||
value: unknown,
|
||||
fallbackName = "AI-CAD 工作台"
|
||||
) {
|
||||
return typeof value === "string" && value.trim()
|
||||
? value.trim()
|
||||
: fallbackName;
|
||||
}
|
||||
|
||||
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
|
||||
name?: string;
|
||||
prompt: string;
|
||||
generationProvider: CadGenerationProvider;
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId: string | null;
|
||||
} {
|
||||
return {
|
||||
...(typeof body.name === "string" && body.name.trim()
|
||||
? { name: body.name.trim() }
|
||||
: {}),
|
||||
prompt: String(body.prompt || ""),
|
||||
generationProvider:
|
||||
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
|
||||
modelVersion:
|
||||
typeof body.modelVersion === "string" && body.modelVersion.trim()
|
||||
? body.modelVersion
|
||||
: "Turbo-v1.0-20250506",
|
||||
viewerSettings: normalizeViewerSettings(body.viewerSettings),
|
||||
messages: normalizeJsonRecordArray(body.messages),
|
||||
versions: normalizeJsonRecordArray(body.versions),
|
||||
activeVersionId:
|
||||
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
|
||||
? body.activeVersionId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultWorkspaceName(index: number) {
|
||||
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
|
||||
}
|
||||
|
||||
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
|
||||
const versions = getWorkspaceVersions(workspace.versions);
|
||||
const versionCount = versions.length;
|
||||
const completedCount = versions.filter(
|
||||
(version) => version.status === "success"
|
||||
).length;
|
||||
const hasPendingTasks = versions.some(
|
||||
(version) =>
|
||||
typeof version.status !== "string" ||
|
||||
!FINAL_TASK_STATUS.has(version.status)
|
||||
);
|
||||
const latestPrompt =
|
||||
typeof workspace.prompt === "string" && workspace.prompt.trim()
|
||||
? workspace.prompt.trim()
|
||||
: versions.find(
|
||||
(version) =>
|
||||
typeof version.prompt === "string" && version.prompt.trim()
|
||||
)?.prompt || null;
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
generationProvider: workspace.generationProvider,
|
||||
versionCount,
|
||||
completedCount,
|
||||
hasPendingTasks,
|
||||
latestPrompt:
|
||||
typeof latestPrompt === "string" && latestPrompt.trim()
|
||||
? latestPrompt.trim()
|
||||
: null,
|
||||
createdAt: workspace.createdAt?.toISOString() || null,
|
||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
prompt: workspace.prompt,
|
||||
generationProvider: workspace.generationProvider,
|
||||
modelVersion: workspace.modelVersion,
|
||||
viewerSettings: workspace.viewerSettings,
|
||||
messages: workspace.messages,
|
||||
versions: workspace.versions,
|
||||
activeVersionId: workspace.activeVersionId,
|
||||
createdAt: workspace.createdAt?.toISOString() || null,
|
||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||
};
|
||||
}
|
||||
89
app/api/ai-cad/workspace/route.ts
Normal file
89
app/api/ai-cad/workspace/route.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createWorkspaceDocument,
|
||||
normalizeWorkspacePayload,
|
||||
type UpdateAiCadWorkspaceRequest,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
createAiCadWorkspace,
|
||||
listAiCadWorkspacesByUserId,
|
||||
updateAiCadWorkspace,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const workspace = workspaces[0] ?? null;
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createWorkspaceDocument(workspace),
|
||||
});
|
||||
}
|
||||
|
||||
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()) as UpdateAiCadWorkspaceRequest;
|
||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const targetWorkspace =
|
||||
existingWorkspaces[0] ??
|
||||
(await createAiCadWorkspace({
|
||||
userId: session.user.id,
|
||||
name: "AI-CAD 工作台",
|
||||
}));
|
||||
|
||||
if (!targetWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "初始化 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const savedWorkspace = await updateAiCadWorkspace({
|
||||
workspaceId: targetWorkspace.id,
|
||||
userId: session.user.id,
|
||||
...normalizeWorkspacePayload(body),
|
||||
});
|
||||
|
||||
if (!savedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "保存 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createWorkspaceDocument,
|
||||
createWorkspaceSummary,
|
||||
normalizeWorkspacePayload,
|
||||
type UpdateAiCadWorkspaceRequest,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
deleteAiCadWorkspaceById,
|
||||
getAiCadWorkspaceById,
|
||||
updateAiCadWorkspace,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
workspaceId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const workspace = await getAiCadWorkspaceById({
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createWorkspaceDocument(workspace),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||
const savedWorkspace = await updateAiCadWorkspace({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
...normalizeWorkspacePayload(body),
|
||||
});
|
||||
|
||||
if (!savedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: savedWorkspace.id,
|
||||
name: savedWorkspace.name,
|
||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||
summary: createWorkspaceSummary(savedWorkspace),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const deletedWorkspace = await deleteAiCadWorkspaceById({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (!deletedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: deletedWorkspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
69
app/api/ai-cad/workspaces/route.ts
Normal file
69
app/api/ai-cad/workspaces/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
buildDefaultWorkspaceName,
|
||||
createWorkspaceSummary,
|
||||
normalizeWorkspaceName,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
createAiCadWorkspace,
|
||||
listAiCadWorkspacesByUserId,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: workspaces.map(createWorkspaceSummary),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(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 { name?: unknown };
|
||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const workspace = await createAiCadWorkspace({
|
||||
userId: session.user.id,
|
||||
name: normalizeWorkspaceName(
|
||||
body.name,
|
||||
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
|
||||
),
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "创建 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: createWorkspaceSummary(workspace),
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
6239
components/ai-cad/ai-cad-workbench.tsx
Normal file
6239
components/ai-cad/ai-cad-workbench.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,21 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
Box,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Clapperboard,
|
||||
Crown,
|
||||
Edit,
|
||||
ExternalLink,
|
||||
Images,
|
||||
LayoutGrid,
|
||||
Medal,
|
||||
PlayCircle,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Wand2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X, BarChart3, Clapperboard } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
import { toast } from "sonner";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
|
||||
type RankingItem = {
|
||||
@ -80,38 +98,35 @@ export function TopNavbar({
|
||||
}: TopNavbarProps) {
|
||||
const router = useRouter();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||||
const [rankingOpen, setRankingOpen] = useState(false);
|
||||
const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false);
|
||||
const [showTutorialModal, setShowTutorialModal] = useState(false);
|
||||
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(null);
|
||||
const [currentTutorialVideo, setCurrentTutorialVideo] =
|
||||
useState<TutorialVideo | null>(null);
|
||||
const [showImageEditor, setShowImageEditor] = useState(false);
|
||||
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
||||
// 移除 fileInputRef,不再需要外部上传
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 获取精选榜单数据
|
||||
const fetchRanking = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/featured/ranking");
|
||||
const data = await res.json();
|
||||
if (data.ranking) {
|
||||
setRanking(data.ranking);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch ranking:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 当下拉菜单打开时获取数据
|
||||
useEffect(() => {
|
||||
if (rankingOpen) {
|
||||
fetchRanking();
|
||||
if (!rankingOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRanking = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/featured/ranking");
|
||||
const data = await res.json();
|
||||
if (data.ranking) {
|
||||
setRanking(data.ranking);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch ranking:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadRanking();
|
||||
}, [rankingOpen]);
|
||||
|
||||
// 获取排名图标
|
||||
@ -124,36 +139,75 @@ export function TopNavbar({
|
||||
case 3:
|
||||
return <Award className="h-4 w-4 text-amber-600" />;
|
||||
default:
|
||||
return <span className="w-4 h-4 text-center text-xs font-bold text-gray-500">{rank}</span>;
|
||||
return (
|
||||
<span className="h-4 w-4 text-center font-bold text-gray-500 text-xs">
|
||||
{rank}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 决定显示哪个 LOGO(浅色用普通,深色用暗色)
|
||||
const displayLogo = resolvedTheme === "dark"
|
||||
? (siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo)
|
||||
: (siteLogo && siteLogo.trim() !== "" ? siteLogo : siteLogoDark);
|
||||
const displayLogo =
|
||||
resolvedTheme === "dark"
|
||||
? siteLogoDark && siteLogoDark.trim() !== ""
|
||||
? siteLogoDark
|
||||
: siteLogo
|
||||
: siteLogo && siteLogo.trim() !== ""
|
||||
? siteLogo
|
||||
: siteLogoDark;
|
||||
|
||||
const getShortLabel = (label: string) => {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed === "AI精选") return "精选";
|
||||
if (trimmed === "功能教程") return "教程";
|
||||
if (trimmed === "精选榜") return "榜单";
|
||||
if (trimmed === "图片编辑") return "编辑";
|
||||
if (trimmed === "智能体") return "智能";
|
||||
if (trimmed === "视频画布") return "视频";
|
||||
if (trimmed === "无限画布") return "画布";
|
||||
if (trimmed === "我的图册") return "图册";
|
||||
if (trimmed === "商品策划") return "策划";
|
||||
if (trimmed === "系统设置") return "设置";
|
||||
if (trimmed === "工具箱") return "工具";
|
||||
if (trimmed === "工具箱2") return "工具";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "AI精选") {
|
||||
return "精选";
|
||||
}
|
||||
if (trimmed === "功能教程") {
|
||||
return "教程";
|
||||
}
|
||||
if (trimmed === "精选榜") {
|
||||
return "榜单";
|
||||
}
|
||||
if (trimmed === "图片编辑") {
|
||||
return "编辑";
|
||||
}
|
||||
if (trimmed === "智能体") {
|
||||
return "智能";
|
||||
}
|
||||
if (trimmed === "视频画布") {
|
||||
return "视频";
|
||||
}
|
||||
if (trimmed === "无限画布") {
|
||||
return "画布";
|
||||
}
|
||||
if (trimmed === "我的图册") {
|
||||
return "图册";
|
||||
}
|
||||
if (trimmed === "商品策划") {
|
||||
return "策划";
|
||||
}
|
||||
if (trimmed === "AI-CAD") {
|
||||
return "CAD";
|
||||
}
|
||||
if (trimmed === "系统设置") {
|
||||
return "设置";
|
||||
}
|
||||
if (trimmed === "工具箱") {
|
||||
return "工具";
|
||||
}
|
||||
if (trimmed === "工具箱2") {
|
||||
return "工具";
|
||||
}
|
||||
return Array.from(trimmed).slice(0, 2).join("");
|
||||
};
|
||||
|
||||
const navItemBaseClass =
|
||||
"flex h-12 w-12 flex-col items-center justify-center gap-1 rounded-xl text-gray-800 hover:bg-black/5 transition-colors dark:text-white dark:hover:bg-white/10";
|
||||
const navLabelClass = "text-[10px] leading-none font-medium text-gray-700 dark:text-white/90";
|
||||
const navLabelClass =
|
||||
"text-[10px] leading-none font-medium text-gray-700 dark:text-white/90";
|
||||
|
||||
// 打开图片编辑器 - 直接打开,不触发上传
|
||||
const handleOpenImageEditor = () => {
|
||||
@ -186,7 +240,7 @@ export function TopNavbar({
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
|
||||
try {
|
||||
sessionStorage.setItem("pending-edited-image", finalUrl);
|
||||
@ -210,11 +264,11 @@ export function TopNavbar({
|
||||
};
|
||||
|
||||
const navContent = (
|
||||
<nav className="fixed left-0 top-0 bottom-0 z-[100] flex w-16 flex-col items-center bg-white/90 backdrop-blur-sm text-gray-900 border-r border-gray-200 shadow-sm dark:bg-black/90 dark:text-white dark:border-white/10">
|
||||
<nav className="fixed top-0 bottom-0 left-0 z-[100] flex w-16 flex-col items-center border-gray-200 border-r bg-white/90 text-gray-900 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-black/90 dark:text-white">
|
||||
{/* 顶部:LOGO + 网站名称 */}
|
||||
<a
|
||||
href="/"
|
||||
className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80"
|
||||
href="/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = "/";
|
||||
@ -222,101 +276,129 @@ export function TopNavbar({
|
||||
>
|
||||
{displayLogo && (
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={siteName}
|
||||
className="h-7 w-7 object-contain"
|
||||
src={displayLogo}
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-[4.5rem] text-center text-[10px] leading-tight line-clamp-2">{siteName}</span>
|
||||
<span className="line-clamp-2 max-w-[4.5rem] text-center text-[10px] leading-tight">
|
||||
{siteName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* 侧边功能区 */}
|
||||
<div className="mt-12 flex w-full flex-1 flex-col items-center gap-3 overflow-y-auto px-2 pb-4">
|
||||
{/* 工具箱1 (如果启用且有链接) */}
|
||||
{toolbox1Enabled && customLinks.filter(l => (l.toolboxGroup || 1) === 1).length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${navItemBaseClass} p-0`}
|
||||
title={toolbox1Name}
|
||||
type="button"
|
||||
{toolbox1Enabled &&
|
||||
customLinks.filter((l) => (l.toolboxGroup || 1) === 1).length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={`${navItemBaseClass} p-0`}
|
||||
size="sm"
|
||||
title={toolbox1Name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Briefcase className="h-5 w-5" />
|
||||
<span className={navLabelClass}>
|
||||
{getShortLabel(toolbox1Name)}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="w-[320px] p-2"
|
||||
side="right"
|
||||
>
|
||||
<Briefcase className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel(toolbox1Name)}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{customLinks
|
||||
.filter(l => (l.toolboxGroup || 1) === 1)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((link) => (
|
||||
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{customLinks
|
||||
.filter((l) => (l.toolboxGroup || 1) === 1)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((link) => (
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="cursor-pointer"
|
||||
key={link.id}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5 opacity-70" />
|
||||
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<a
|
||||
className="flex h-20 flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 text-center transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
href={link.url}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="h-5 w-5 opacity-70" />
|
||||
<span className="line-clamp-2 font-medium text-xs">
|
||||
{link.name}
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* 工具箱2 (如果启用且有链接) */}
|
||||
{toolbox2Enabled && customLinks.filter(l => l.toolboxGroup === 2).length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${navItemBaseClass} p-0`}
|
||||
title={toolbox2Name}
|
||||
type="button"
|
||||
{toolbox2Enabled &&
|
||||
customLinks.filter((l) => l.toolboxGroup === 2).length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={`${navItemBaseClass} p-0`}
|
||||
size="sm"
|
||||
title={toolbox2Name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Briefcase className="h-5 w-5" />
|
||||
<span className={navLabelClass}>
|
||||
{getShortLabel(toolbox2Name)}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="w-[320px] p-2"
|
||||
side="right"
|
||||
>
|
||||
<Briefcase className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel(toolbox2Name)}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{customLinks
|
||||
.filter(l => l.toolboxGroup === 2)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((link) => (
|
||||
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{customLinks
|
||||
.filter((l) => l.toolboxGroup === 2)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((link) => (
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="cursor-pointer"
|
||||
key={link.id}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5 opacity-70" />
|
||||
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<a
|
||||
className="flex h-20 flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 text-center transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
href={link.url}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="h-5 w-5 opacity-70" />
|
||||
<span className="line-clamp-2 font-medium text-xs">
|
||||
{link.name}
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* AI智能精选 */}
|
||||
<a
|
||||
href="/featured"
|
||||
className={navItemBaseClass}
|
||||
title="AI精选"
|
||||
href="/featured"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = "/featured";
|
||||
}}
|
||||
title="AI精选"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("AI精选")}</span>
|
||||
@ -324,68 +406,94 @@ export function TopNavbar({
|
||||
|
||||
{productPlanningEnabled && (
|
||||
<a
|
||||
href="/product-planning"
|
||||
className={navItemBaseClass}
|
||||
title="商品策划"
|
||||
href="/product-planning"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = "/product-planning";
|
||||
}}
|
||||
title="商品策划"
|
||||
>
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("商品策划")}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
className={navItemBaseClass}
|
||||
href="/ai-cad"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = "/ai-cad";
|
||||
}}
|
||||
title="AI-CAD"
|
||||
>
|
||||
<Box className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("AI-CAD")}</span>
|
||||
</a>
|
||||
|
||||
{/* 功能教程 */}
|
||||
{tutorialEnabled && tutorialVideos.length > 0 && (
|
||||
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>
|
||||
<DropdownMenu
|
||||
onOpenChange={setTutorialMenuOpen}
|
||||
open={tutorialMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${navItemBaseClass} p-0`}
|
||||
type="button"
|
||||
size="sm"
|
||||
title="功能教程"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PlayCircle className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("功能教程")}</span>
|
||||
<span className={navLabelClass}>
|
||||
{getShortLabel("功能教程")}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="w-[320px] p-2"
|
||||
side="right"
|
||||
>
|
||||
<div className="max-h-[400px] space-y-1 overflow-y-auto">
|
||||
{tutorialVideos
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((video) => (
|
||||
<DropdownMenuItem
|
||||
key={video.id}
|
||||
className="cursor-pointer p-0"
|
||||
key={video.id}
|
||||
onClick={() => {
|
||||
setCurrentTutorialVideo(video);
|
||||
setShowTutorialModal(true);
|
||||
setTutorialMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-2 w-full hover:bg-accent rounded-md">
|
||||
<div className="flex w-full items-center gap-3 rounded-md p-2 hover:bg-accent">
|
||||
{/* 封面 */}
|
||||
<div className="w-16 h-10 bg-muted rounded overflow-hidden flex-shrink-0">
|
||||
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
|
||||
{video.coverUrl ? (
|
||||
<img
|
||||
src={video.coverUrl}
|
||||
alt={video.title}
|
||||
className="w-full h-full object-cover"
|
||||
<img
|
||||
alt={video.title}
|
||||
className="h-full w-full object-cover"
|
||||
src={video.coverUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-green-500/20 to-blue-500/20">
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-green-500/20 to-blue-500/20">
|
||||
<PlayCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 标题和描述 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{video.title}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm">
|
||||
{video.title}
|
||||
</div>
|
||||
{video.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">{video.description}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{video.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -398,53 +506,66 @@ export function TopNavbar({
|
||||
|
||||
{/* 精选榜单 */}
|
||||
{rankingEnabled && (
|
||||
<DropdownMenu open={rankingOpen} onOpenChange={setRankingOpen}>
|
||||
<DropdownMenu onOpenChange={setRankingOpen} open={rankingOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${navItemBaseClass} p-0`}
|
||||
type="button"
|
||||
size="sm"
|
||||
title="精选榜单"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trophy className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("精选榜")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="center" className="w-[280px] p-3">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="w-[280px] p-3"
|
||||
side="right"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2 border-b pb-2">
|
||||
<Trophy className="h-5 w-5 text-orange-500" />
|
||||
<span className="font-semibold">精选榜单 TOP5</span>
|
||||
</div>
|
||||
{ranking.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4 text-sm">
|
||||
<div className="py-4 text-center text-muted-foreground text-sm">
|
||||
暂无精选数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ranking.map((item) => (
|
||||
<div
|
||||
key={item.userId}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg transition-colors ${
|
||||
item.rank === 1 ? "bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20" :
|
||||
item.rank === 2 ? "bg-gray-50 dark:bg-gray-800/50" :
|
||||
item.rank === 3 ? "bg-amber-50 dark:bg-amber-900/20" :
|
||||
"hover:bg-muted"
|
||||
className={`flex items-center gap-3 rounded-lg p-2 transition-colors ${
|
||||
item.rank === 1
|
||||
? "bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20"
|
||||
: item.rank === 2
|
||||
? "bg-gray-50 dark:bg-gray-800/50"
|
||||
: item.rank === 3
|
||||
? "bg-amber-50 dark:bg-amber-900/20"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
key={item.userId}
|
||||
>
|
||||
<div className="flex items-center justify-center w-6">
|
||||
<div className="flex w-6 items-center justify-center">
|
||||
{getRankIcon(item.rank)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{item.userName}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1 truncate">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">
|
||||
{item.userName}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 truncate text-muted-foreground text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{item.department}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-orange-500">{item.featuredCount}</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">精选</span>
|
||||
<span className="font-bold text-lg text-orange-500">
|
||||
{item.featuredCount}
|
||||
</span>
|
||||
<span className="ml-1 text-muted-foreground text-xs">
|
||||
精选
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -456,12 +577,12 @@ export function TopNavbar({
|
||||
|
||||
{/* 图片编辑 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${navItemBaseClass} p-0`}
|
||||
onClick={handleOpenImageEditor}
|
||||
type="button"
|
||||
size="sm"
|
||||
title="图片编辑"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("图片编辑")}</span>
|
||||
@ -474,8 +595,8 @@ export function TopNavbar({
|
||||
onClick={() => {
|
||||
window.location.href = "/canvas?mode=agent";
|
||||
}}
|
||||
type="button"
|
||||
title="智能体"
|
||||
type="button"
|
||||
>
|
||||
<Wand2 className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("智能体")}</span>
|
||||
@ -487,8 +608,8 @@ export function TopNavbar({
|
||||
onClick={() => {
|
||||
window.location.href = "/video-canvas";
|
||||
}}
|
||||
type="button"
|
||||
title="视频画布"
|
||||
type="button"
|
||||
>
|
||||
<Clapperboard className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
|
||||
@ -501,8 +622,8 @@ export function TopNavbar({
|
||||
const newId = crypto.randomUUID();
|
||||
window.location.href = `/canvas/${newId}`;
|
||||
}}
|
||||
type="button"
|
||||
title="无限画布"
|
||||
type="button"
|
||||
>
|
||||
<LayoutGrid className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("无限画布")}</span>
|
||||
@ -510,13 +631,13 @@ export function TopNavbar({
|
||||
|
||||
{/* 我的图册 */}
|
||||
<a
|
||||
href="/gallery"
|
||||
className={navItemBaseClass}
|
||||
title="我的图册"
|
||||
href="/gallery"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = "/gallery";
|
||||
}}
|
||||
title="我的图册"
|
||||
>
|
||||
<Images className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("我的图册")}</span>
|
||||
@ -527,13 +648,13 @@ export function TopNavbar({
|
||||
{(isAdmin || settingsHref) && (
|
||||
<div className="pb-4">
|
||||
<a
|
||||
href={settingsHref || "/settings"}
|
||||
className={navItemBaseClass}
|
||||
title="系统设置"
|
||||
href={settingsHref || "/settings"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = settingsHref || "/settings";
|
||||
}}
|
||||
title="系统设置"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("系统设置")}</span>
|
||||
@ -545,94 +666,102 @@ export function TopNavbar({
|
||||
{showImageEditor && (
|
||||
<ImageAnnotator
|
||||
imageUrl={editorImageUrl}
|
||||
onCancel={handleImageEditorCancel}
|
||||
onConfirm={handleImageEditorConfirm}
|
||||
submitMode="publish-new-canvas"
|
||||
onCancel={handleImageEditorCancel}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
|
||||
// 使用 Portal 将视频弹窗渲染到 body,确保它在最顶层且不受 nav 的限制
|
||||
const videoModal = showTutorialModal && currentTutorialVideo && typeof document !== 'undefined' ? createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] bg-black/95"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: 0,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowTutorialModal(false);
|
||||
setCurrentTutorialVideo(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-[1200px]"
|
||||
style={{ maxHeight: 'calc(100vh - 80px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 标题栏 - 包含标题和关闭按钮 */}
|
||||
<div className="flex items-center justify-between mb-3 px-2">
|
||||
<div className="w-8" /> {/* 占位符保持标题居中 */}
|
||||
<h3 className="text-white text-lg font-medium truncate flex-1 text-center">{currentTutorialVideo.title}</h3>
|
||||
<button
|
||||
const videoModal =
|
||||
showTutorialModal && currentTutorialVideo && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] bg-black/95"
|
||||
onClick={() => {
|
||||
setShowTutorialModal(false);
|
||||
setCurrentTutorialVideo(null);
|
||||
}}
|
||||
className="text-white hover:text-red-400 transition-colors z-10 p-1"
|
||||
title="关闭 (ESC)"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: 0,
|
||||
padding: "20px",
|
||||
}}
|
||||
>
|
||||
<X className="h-8 w-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器 */}
|
||||
{currentTutorialVideo.videoUrl.includes('youtube.com') || currentTutorialVideo.videoUrl.includes('youtu.be') ? (
|
||||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
|
||||
<iframe
|
||||
src={`${currentTutorialVideo.videoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}?autoplay=1&rel=0&modestbranding=1`}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
|
||||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
|
||||
<iframe
|
||||
src={`${currentTutorialVideo.videoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}&autoplay=1&high_quality=1`}
|
||||
className="w-full h-full"
|
||||
allowFullScreen
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<VideoPlayer
|
||||
src={currentTutorialVideo.videoUrl}
|
||||
poster={currentTutorialVideo.coverUrl || undefined}
|
||||
title={currentTutorialVideo.title}
|
||||
autoPlay={true}
|
||||
className="shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="relative w-full max-w-[1200px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxHeight: "calc(100vh - 80px)" }}
|
||||
>
|
||||
{/* 标题栏 - 包含标题和关闭按钮 */}
|
||||
<div className="mb-3 flex items-center justify-between px-2">
|
||||
<div className="w-8" /> {/* 占位符保持标题居中 */}
|
||||
<h3 className="flex-1 truncate text-center font-medium text-lg text-white">
|
||||
{currentTutorialVideo.title}
|
||||
</h3>
|
||||
<button
|
||||
className="z-10 p-1 text-white transition-colors hover:text-red-400"
|
||||
onClick={() => {
|
||||
setShowTutorialModal(false);
|
||||
setCurrentTutorialVideo(null);
|
||||
}}
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
<X className="h-8 w-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频描述 */}
|
||||
{currentTutorialVideo.description && (
|
||||
<p className="text-gray-300 text-sm mt-3 text-center">{currentTutorialVideo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
) : null;
|
||||
{/* 视频播放器 */}
|
||||
{currentTutorialVideo.videoUrl.includes("youtube.com") ||
|
||||
currentTutorialVideo.videoUrl.includes("youtu.be") ? (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black shadow-2xl">
|
||||
<iframe
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
className="h-full w-full"
|
||||
loading="eager"
|
||||
src={`${currentTutorialVideo.videoUrl.replace("watch?v=", "embed/").replace("youtu.be/", "youtube.com/embed/")}?autoplay=1&rel=0&modestbranding=1`}
|
||||
/>
|
||||
</div>
|
||||
) : currentTutorialVideo.videoUrl.includes("bilibili.com") ? (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black shadow-2xl">
|
||||
<iframe
|
||||
allowFullScreen
|
||||
className="h-full w-full"
|
||||
loading="eager"
|
||||
src={`${currentTutorialVideo.videoUrl.replace("www.bilibili.com/video/", "player.bilibili.com/player.html?bvid=")}&autoplay=1&high_quality=1`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<VideoPlayer
|
||||
autoPlay={true}
|
||||
className="shadow-2xl"
|
||||
poster={currentTutorialVideo.coverUrl || undefined}
|
||||
src={currentTutorialVideo.videoUrl}
|
||||
title={currentTutorialVideo.title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 视频描述 */}
|
||||
{currentTutorialVideo.description && (
|
||||
<p className="mt-3 text-center text-gray-300 text-sm">
|
||||
{currentTutorialVideo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -19,6 +19,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
||||
"fal-video": "https://fal.run",
|
||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||
"worldlabs-3d": "https://api.worldlabs.ai",
|
||||
"kittycad-cad": "https://api.zoo.dev",
|
||||
runway: "https://api.dev.runwayml.com",
|
||||
};
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ const providerBaseUrlDefaults = [
|
||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
||||
{ type: "worldlabs-3d", baseUrl: "https://api.worldlabs.ai" },
|
||||
{ type: "kittycad-cad", baseUrl: "https://api.zoo.dev" },
|
||||
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
|
||||
] as const;
|
||||
|
||||
@ -94,7 +95,7 @@ const runMigrate = async () => {
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (type IN (
|
||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
||||
'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'runway'
|
||||
'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway'
|
||||
));
|
||||
`);
|
||||
await db.execute(sql`
|
||||
|
||||
32
lib/db/migrations/0065_add_kittycad_provider_type.sql
Normal file
32
lib/db/migrations/0065_add_kittycad_provider_type.sql
Normal file
@ -0,0 +1,32 @@
|
||||
ALTER TABLE "AiProvider"
|
||||
DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
|
||||
|
||||
ALTER TABLE "AiProvider"
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (
|
||||
"type" IN (
|
||||
'alibaba',
|
||||
'openai',
|
||||
'custom',
|
||||
'local',
|
||||
'gemini',
|
||||
'huanke-gemini',
|
||||
'qianwen-video',
|
||||
'doubao-video',
|
||||
'ruxa-video',
|
||||
'volcano-vision',
|
||||
'volcano-video',
|
||||
'alibaba-image',
|
||||
'bfl-image',
|
||||
'ruxa-image',
|
||||
'fal-image',
|
||||
'openai-image',
|
||||
'midjourney',
|
||||
'fal-video',
|
||||
'zhipu',
|
||||
'tripo-3d',
|
||||
'worldlabs-3d',
|
||||
'kittycad-cad',
|
||||
'runway'
|
||||
)
|
||||
);
|
||||
23
lib/db/migrations/0066_add_ai_cad_workspace.sql
Normal file
23
lib/db/migrations/0066_add_ai_cad_workspace.sql
Normal file
@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS "AiCadWorkspace" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"prompt" text DEFAULT '' NOT NULL,
|
||||
"generationProvider" varchar(32) DEFAULT 'kittycad-cad' NOT NULL,
|
||||
"modelVersion" varchar(128) DEFAULT 'Turbo-v1.0-20250506' NOT NULL,
|
||||
"viewerSettings" jsonb DEFAULT '{"envPreset":"studio","lightIntensity":1.1,"autoRotate":true,"showGrid":true,"wireframe":false}'::jsonb NOT NULL,
|
||||
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"versions" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"activeVersionId" varchar(64),
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "AiCadWorkspace_userId_unique" UNIQUE("userId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AiCadWorkspace" ADD CONSTRAINT "AiCadWorkspace_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "AiCadWorkspace_user_updated_idx"
|
||||
ON "AiCadWorkspace"("userId", "updatedAt");
|
||||
5
lib/db/migrations/0067_add_ai_cad_multi_workspace.sql
Normal file
5
lib/db/migrations/0067_add_ai_cad_multi_workspace.sql
Normal file
@ -0,0 +1,5 @@
|
||||
ALTER TABLE "AiCadWorkspace"
|
||||
DROP CONSTRAINT IF EXISTS "AiCadWorkspace_userId_unique";
|
||||
|
||||
ALTER TABLE "AiCadWorkspace"
|
||||
ADD COLUMN IF NOT EXISTS "name" varchar(128) NOT NULL DEFAULT 'AI-CAD 工作台';
|
||||
@ -358,6 +358,27 @@
|
||||
"when": 1775342400000,
|
||||
"tag": "0064_add_model3d_asset_share",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 51,
|
||||
"version": "7",
|
||||
"when": 1775376000000,
|
||||
"tag": "0065_add_kittycad_provider_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 52,
|
||||
"version": "7",
|
||||
"when": 1775419200000,
|
||||
"tag": "0066_add_ai_cad_workspace",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "7",
|
||||
"when": 1775462400000,
|
||||
"tag": "0067_add_ai_cad_multi_workspace",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -44,6 +44,8 @@ import {
|
||||
type User,
|
||||
user,
|
||||
vote,
|
||||
aiCadWorkspace,
|
||||
type AiCadWorkspace,
|
||||
aiProvider,
|
||||
type AiProvider,
|
||||
aiModel,
|
||||
@ -742,6 +744,156 @@ export async function getConversationWorkingStateByChatId({
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAiCadWorkspacesByUserId({
|
||||
userId,
|
||||
}: {
|
||||
userId: string;
|
||||
}): Promise<AiCadWorkspace[]> {
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(aiCadWorkspace)
|
||||
.where(eq(aiCadWorkspace.userId, userId))
|
||||
.orderBy(desc(aiCadWorkspace.updatedAt));
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to list AI-CAD workspaces"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiCadWorkspaceById(params: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(aiCadWorkspace)
|
||||
.where(
|
||||
and(
|
||||
eq(aiCadWorkspace.userId, params.userId),
|
||||
eq(aiCadWorkspace.id, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to get AI-CAD workspace by id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAiCadWorkspace(params: {
|
||||
userId: string;
|
||||
name: string;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [result] = await db
|
||||
.insert(aiCadWorkspace)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
prompt: "",
|
||||
generationProvider: "kittycad-cad",
|
||||
modelVersion: "Turbo-v1.0-20250506",
|
||||
viewerSettings: {
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1.1,
|
||||
autoRotate: true,
|
||||
showGrid: true,
|
||||
wireframe: false,
|
||||
},
|
||||
messages: [],
|
||||
versions: [],
|
||||
activeVersionId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to create AI-CAD workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAiCadWorkspace(params: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId?: string | null;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [result] = await db
|
||||
.update(aiCadWorkspace)
|
||||
.set({
|
||||
...(params.name ? { name: params.name } : {}),
|
||||
prompt: params.prompt,
|
||||
generationProvider: params.generationProvider,
|
||||
modelVersion: params.modelVersion,
|
||||
viewerSettings: params.viewerSettings,
|
||||
messages: params.messages,
|
||||
versions: params.versions,
|
||||
activeVersionId: params.activeVersionId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(aiCadWorkspace.id, params.workspaceId),
|
||||
eq(aiCadWorkspace.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to update AI-CAD workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiCadWorkspaceById(params: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const [result] = await db
|
||||
.delete(aiCadWorkspace)
|
||||
.where(
|
||||
and(
|
||||
eq(aiCadWorkspace.id, params.workspaceId),
|
||||
eq(aiCadWorkspace.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to delete AI-CAD workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertConversationWorkingState(params: {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
|
||||
@ -264,7 +264,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
||||
export const aiProvider = pgTable("AiProvider", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "runway", "bfl-image"] })
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "bfl-image"] })
|
||||
.notNull()
|
||||
.default("custom"), // 提供商类型
|
||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||
@ -907,6 +907,32 @@ export const conversationWorkingState = pgTable("ConversationWorkingState", {
|
||||
|
||||
export type ConversationWorkingState = InferSelectModel<typeof conversationWorkingState>;
|
||||
|
||||
export const aiCadWorkspace = pgTable("AiCadWorkspace", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 128 }).notNull().default("AI-CAD 工作台"),
|
||||
prompt: text("prompt").notNull().default(""),
|
||||
generationProvider: varchar("generationProvider", {
|
||||
length: 32,
|
||||
enum: ["kittycad-cad", "tripo-3d"],
|
||||
})
|
||||
.notNull()
|
||||
.default("kittycad-cad"),
|
||||
modelVersion: varchar("modelVersion", { length: 128 })
|
||||
.notNull()
|
||||
.default("Turbo-v1.0-20250506"),
|
||||
viewerSettings: jsonb("viewerSettings").notNull(),
|
||||
messages: jsonb("messages").$type<Record<string, unknown>[]>().notNull().default([]),
|
||||
versions: jsonb("versions").$type<Record<string, unknown>[]>().notNull().default([]),
|
||||
activeVersionId: varchar("activeVersionId", { length: 64 }),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type AiCadWorkspace = InferSelectModel<typeof aiCadWorkspace>;
|
||||
|
||||
// 每轮消息的目标资产/图片定位审计表
|
||||
export const messageTargetResolution = pgTable("MessageTargetResolution", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
|
||||
134
lib/kittycad/client.ts
Normal file
134
lib/kittycad/client.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import "server-only";
|
||||
|
||||
import type { KittyCadProviderConfig } from "./server";
|
||||
|
||||
type KittyCadRequestOptions = {
|
||||
method?: "GET" | "POST";
|
||||
path: string;
|
||||
json?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
const DEFAULT_KITTYCAD_BASE_URL = "https://api.zoo.dev";
|
||||
const TRAILING_SLASH_RE = /\/+$/;
|
||||
|
||||
export class KittyCadApiError extends Error {
|
||||
httpStatus: number;
|
||||
payload: unknown;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
httpStatus: number;
|
||||
payload?: unknown;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "KittyCadApiError";
|
||||
this.httpStatus = params.httpStatus;
|
||||
this.payload = params.payload;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl?: string) {
|
||||
const resolved = String(baseUrl || "").trim() || DEFAULT_KITTYCAD_BASE_URL;
|
||||
return resolved.replace(TRAILING_SLASH_RE, "");
|
||||
}
|
||||
|
||||
function sanitizeApiKey(value: string): string {
|
||||
let result = "";
|
||||
|
||||
for (const char of String(value || "")) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code === 0x20_28 || code === 0x20_29) {
|
||||
continue;
|
||||
}
|
||||
if ((code >= 0 && code <= 0x1f) || code === 0x7f) {
|
||||
continue;
|
||||
}
|
||||
result += char;
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
const text = await response.text().catch(() => "");
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown, fallback: string) {
|
||||
if (!payload) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof payload === "string") {
|
||||
return payload || fallback;
|
||||
}
|
||||
if (typeof payload !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.error === "string" && record.error.trim()) {
|
||||
return record.error.trim();
|
||||
}
|
||||
if (typeof record.message === "string" && record.message.trim()) {
|
||||
return record.message.trim();
|
||||
}
|
||||
if (typeof record.detail === "string" && record.detail.trim()) {
|
||||
return record.detail.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function kittyCadRequest<T = unknown>(
|
||||
config: KittyCadProviderConfig,
|
||||
options: KittyCadRequestOptions
|
||||
): Promise<T> {
|
||||
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
|
||||
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
|
||||
|
||||
const headers = new Headers(options.headers || {});
|
||||
const normalizedApiKey = sanitizeApiKey(String(config.apiKey || "")).replace(
|
||||
/\s+/g,
|
||||
""
|
||||
);
|
||||
if (!normalizedApiKey) {
|
||||
throw new KittyCadApiError({
|
||||
message: "KittyCAD API Token 为空,请在本地AI设置中重新保存",
|
||||
httpStatus: 400,
|
||||
});
|
||||
}
|
||||
|
||||
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (options.json) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
const parsed = await parseResponseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new KittyCadApiError({
|
||||
message: extractErrorMessage(
|
||||
parsed,
|
||||
`KittyCAD 请求失败(HTTP ${response.status})`
|
||||
),
|
||||
httpStatus: response.status,
|
||||
payload: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
94
lib/kittycad/server.ts
Normal file
94
lib/kittycad/server.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import "server-only";
|
||||
|
||||
import { getEnabledAiProviders } from "@/lib/db/queries";
|
||||
|
||||
export type KittyCadProviderSource = "provider" | "env";
|
||||
|
||||
export type KittyCadProviderConfig = {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
providerId?: string;
|
||||
providerName?: string;
|
||||
source: KittyCadProviderSource;
|
||||
};
|
||||
|
||||
const DEFAULT_KITTYCAD_BASE_URL = "https://api.zoo.dev";
|
||||
|
||||
function stripUnsafeText(value: string): string {
|
||||
let result = "";
|
||||
|
||||
for (const char of String(value || "")) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code === 0x20_28 || code === 0x20_29) {
|
||||
continue;
|
||||
}
|
||||
if ((code >= 0 && code <= 0x1f) || code === 0x7f) {
|
||||
continue;
|
||||
}
|
||||
result += char;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeKittyCadToken(value: string): string {
|
||||
return stripUnsafeText(value).trim();
|
||||
}
|
||||
|
||||
function sanitizeKittyCadBaseUrl(value: string): string {
|
||||
return stripUnsafeText(value).trim();
|
||||
}
|
||||
|
||||
export async function findKittyCadProviderConfig(): Promise<KittyCadProviderConfig | null> {
|
||||
const providers = await getEnabledAiProviders();
|
||||
const kittyCadProvider = providers.find(
|
||||
(item) => item.type === "kittycad-cad"
|
||||
);
|
||||
|
||||
const providerApiKey = sanitizeKittyCadToken(
|
||||
String(kittyCadProvider?.apiKey || "")
|
||||
);
|
||||
if (kittyCadProvider && providerApiKey) {
|
||||
return {
|
||||
apiKey: providerApiKey,
|
||||
baseUrl:
|
||||
sanitizeKittyCadBaseUrl(String(kittyCadProvider.baseUrl || "")) ||
|
||||
DEFAULT_KITTYCAD_BASE_URL,
|
||||
providerId: kittyCadProvider.id,
|
||||
providerName: kittyCadProvider.name,
|
||||
source: "provider",
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = sanitizeKittyCadToken(
|
||||
String(
|
||||
process.env.KITTYCAD_API_TOKEN ||
|
||||
process.env.KITTYCAD_API_KEY ||
|
||||
process.env.ZOO_API_TOKEN ||
|
||||
""
|
||||
)
|
||||
);
|
||||
|
||||
if (!envApiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: envApiKey,
|
||||
baseUrl:
|
||||
sanitizeKittyCadBaseUrl(
|
||||
String(process.env.KITTYCAD_BASE_URL || process.env.ZOO_BASE_URL || "")
|
||||
) || DEFAULT_KITTYCAD_BASE_URL,
|
||||
source: "env",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getKittyCadProviderConfig(): Promise<KittyCadProviderConfig> {
|
||||
const config = await findKittyCadProviderConfig();
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
"未配置 KittyCAD CAD API,请在系统设置-本地AI新增 KittyCAD CAD 提供商"
|
||||
);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
193
services/ai-cad/index.ts
Normal file
193
services/ai-cad/index.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import type { Model3DTaskStatus } from "@/services/canvas";
|
||||
|
||||
type ApiResponse<T = any> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AiCadProviderStatus = {
|
||||
configured: boolean;
|
||||
source: "provider" | "env" | "none";
|
||||
providerName?: string | null;
|
||||
baseUrl?: string | null;
|
||||
};
|
||||
|
||||
export type AiCadWorkspaceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
versionCount: number;
|
||||
completedCount: number;
|
||||
hasPendingTasks: boolean;
|
||||
latestPrompt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type AiCadWorkspaceDocument = {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export const aiCadGenerationService = {
|
||||
async listWorkspaces(): Promise<ApiResponse<AiCadWorkspaceSummary[]>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/workspaces");
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "读取 AI-CAD 工作台列表失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async createWorkspace(params?: {
|
||||
name?: string;
|
||||
}): Promise<ApiResponse<AiCadWorkspaceSummary>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/workspaces", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params || {}),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "创建 AI-CAD 工作台失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async getWorkspace(
|
||||
workspaceId: string
|
||||
): Promise<ApiResponse<AiCadWorkspaceDocument | null>> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "读取 AI-CAD 工作台失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async saveWorkspace(
|
||||
workspaceId: string,
|
||||
params: {
|
||||
name?: string;
|
||||
prompt: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId?: string | null;
|
||||
}
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt?: string | null;
|
||||
summary?: AiCadWorkspaceSummary;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "保存 AI-CAD 工作台失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkspace(
|
||||
workspaceId: string
|
||||
): Promise<ApiResponse<{ id: string }>> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "删除 AI-CAD 工作台失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async getProviderStatus(): Promise<ApiResponse<AiCadProviderStatus>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/provider");
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "读取 AI-CAD 提供商状态失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async createTask(params: {
|
||||
prompt: string;
|
||||
outputFormat?: string;
|
||||
projectName?: string;
|
||||
iterationSourceTaskId?: string;
|
||||
}): Promise<
|
||||
ApiResponse<{
|
||||
taskId: string;
|
||||
status: Model3DTaskStatus;
|
||||
conversationId?: string | null;
|
||||
modelVersion?: string | null;
|
||||
outputFormat?: string | null;
|
||||
generationMode?: "create" | "iterate";
|
||||
sourceTaskId?: string | null;
|
||||
raw?: any;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "创建 AI-CAD 任务失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<
|
||||
ApiResponse<{
|
||||
taskId: string;
|
||||
type: string;
|
||||
status: Model3DTaskStatus;
|
||||
progress?: number | null;
|
||||
output?: Record<string, any>;
|
||||
modelUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
errorMessage?: string | null;
|
||||
conversationId?: string | null;
|
||||
modelVersion?: string | null;
|
||||
outputFormat?: string | null;
|
||||
codeUrl?: string | null;
|
||||
downloadUrls?: Record<string, string>;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/tasks/${taskId}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return { success: false, error: "查询 AI-CAD 任务失败" };
|
||||
}
|
||||
},
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user