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` 提供商;若未配置则回退到环境变量
|
# 优先使用系统设置-本地AI里的 `tripo-3d` 提供商;若未配置则回退到环境变量
|
||||||
TRIPO_API_KEY=your-tripo-api-key
|
TRIPO_API_KEY=your-tripo-api-key
|
||||||
TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi
|
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"
|
| "zhipu"
|
||||||
| "tripo-3d"
|
| "tripo-3d"
|
||||||
| "worldlabs-3d"
|
| "worldlabs-3d"
|
||||||
|
| "kittycad-cad"
|
||||||
| "runway"
|
| "runway"
|
||||||
| "volcano-multimodal";
|
| "volcano-multimodal";
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@ -655,6 +656,7 @@ const PROVIDER_TYPES = [
|
|||||||
{ value: "midjourney", label: "Midjourney" },
|
{ value: "midjourney", label: "Midjourney" },
|
||||||
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
|
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
|
||||||
{ value: "worldlabs-3d", label: "World Labs 3D 世界生成" },
|
{ value: "worldlabs-3d", label: "World Labs 3D 世界生成" },
|
||||||
|
{ value: "kittycad-cad", label: "KittyCAD / Zoo CAD" },
|
||||||
{ value: "runway", label: "Runway API" },
|
{ value: "runway", label: "Runway API" },
|
||||||
{ value: "zhipu", label: "智普官方 4.7" },
|
{ value: "zhipu", label: "智普官方 4.7" },
|
||||||
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
||||||
@ -683,6 +685,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
|||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
"worldlabs-3d": "https://api.worldlabs.ai",
|
"worldlabs-3d": "https://api.worldlabs.ai",
|
||||||
|
"kittycad-cad": "https://api.zoo.dev",
|
||||||
runway: "https://api.dev.runwayml.com",
|
runway: "https://api.dev.runwayml.com",
|
||||||
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
@ -1371,6 +1374,14 @@ export function AdminSettingsClient() {
|
|||||||
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
|
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(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({
|
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
|
||||||
providerId: "",
|
providerId: "",
|
||||||
modelEntryId: "",
|
modelEntryId: "",
|
||||||
@ -1902,6 +1913,32 @@ export function AdminSettingsClient() {
|
|||||||
[worldlabsProviders]
|
[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({
|
const [navLinkForm, setNavLinkForm] = useState({
|
||||||
id: "",
|
id: "",
|
||||||
@ -2217,6 +2254,7 @@ export function AdminSettingsClient() {
|
|||||||
| "zhipu"
|
| "zhipu"
|
||||||
| "tripo-3d"
|
| "tripo-3d"
|
||||||
| "worldlabs-3d"
|
| "worldlabs-3d"
|
||||||
|
| "kittycad-cad"
|
||||||
| "runway"
|
| "runway"
|
||||||
| "volcano-multimodal",
|
| "volcano-multimodal",
|
||||||
baseUrl: "",
|
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 () => {
|
const handleSaveMoonshotDetailAgentConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const trimmedBaseUrl =
|
const trimmedBaseUrl =
|
||||||
@ -6430,6 +6560,7 @@ export function AdminSettingsClient() {
|
|||||||
midjourney: "bg-fuchsia-600",
|
midjourney: "bg-fuchsia-600",
|
||||||
"tripo-3d": "bg-teal-500",
|
"tripo-3d": "bg-teal-500",
|
||||||
"worldlabs-3d": "bg-sky-600",
|
"worldlabs-3d": "bg-sky-600",
|
||||||
|
"kittycad-cad": "bg-orange-500",
|
||||||
openai: "bg-green-500",
|
openai: "bg-green-500",
|
||||||
zhipu: "bg-lime-500",
|
zhipu: "bg-lime-500",
|
||||||
"volcano-multimodal": "bg-fuchsia-500",
|
"volcano-multimodal": "bg-fuchsia-500",
|
||||||
@ -12716,6 +12847,131 @@ export function AdminSettingsClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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配置 */}
|
{/* 抠图API配置 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
<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",
|
"zhipu",
|
||||||
"tripo-3d",
|
"tripo-3d",
|
||||||
"worldlabs-3d",
|
"worldlabs-3d",
|
||||||
|
"kittycad-cad",
|
||||||
"runway",
|
"runway",
|
||||||
"volcano-multimodal",
|
"volcano-multimodal",
|
||||||
] as const;
|
] 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 MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||||
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
|
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_BASE_URL = "https://api.dev.runwayml.com";
|
||||||
const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/;
|
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.baseUrl || TRIPO_3D_BASE_URL
|
||||||
: data.type === "worldlabs-3d"
|
: data.type === "worldlabs-3d"
|
||||||
? data.baseUrl || WORLDLABS_3D_BASE_URL
|
? data.baseUrl || WORLDLABS_3D_BASE_URL
|
||||||
|
: data.type === "kittycad-cad"
|
||||||
|
? data.baseUrl || KITTYCAD_CAD_BASE_URL
|
||||||
: data.type === "runway"
|
: data.type === "runway"
|
||||||
? data.baseUrl || RUNWAY_BASE_URL
|
? data.baseUrl || RUNWAY_BASE_URL
|
||||||
: data.baseUrl,
|
: 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";
|
"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 { 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 { useTheme } from "next-themes";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ImageAnnotator } from "@/components/image-annotator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ImageAnnotator } from "@/components/image-annotator";
|
|
||||||
import { VideoPlayer } from "@/components/video-player";
|
import { VideoPlayer } from "@/components/video-player";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
|
|
||||||
type RankingItem = {
|
type RankingItem = {
|
||||||
@ -80,38 +98,35 @@ export function TopNavbar({
|
|||||||
}: TopNavbarProps) {
|
}: TopNavbarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||||||
const [rankingOpen, setRankingOpen] = useState(false);
|
const [rankingOpen, setRankingOpen] = useState(false);
|
||||||
const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false);
|
const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false);
|
||||||
const [showTutorialModal, setShowTutorialModal] = 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 [showImageEditor, setShowImageEditor] = useState(false);
|
||||||
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
||||||
// 移除 fileInputRef,不再需要外部上传
|
// 移除 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(() => {
|
useEffect(() => {
|
||||||
if (rankingOpen) {
|
if (!rankingOpen) {
|
||||||
fetchRanking();
|
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]);
|
}, [rankingOpen]);
|
||||||
|
|
||||||
// 获取排名图标
|
// 获取排名图标
|
||||||
@ -124,36 +139,75 @@ export function TopNavbar({
|
|||||||
case 3:
|
case 3:
|
||||||
return <Award className="h-4 w-4 text-amber-600" />;
|
return <Award className="h-4 w-4 text-amber-600" />;
|
||||||
default:
|
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(浅色用普通,深色用暗色)
|
// 决定显示哪个 LOGO(浅色用普通,深色用暗色)
|
||||||
const displayLogo = resolvedTheme === "dark"
|
const displayLogo =
|
||||||
? (siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo)
|
resolvedTheme === "dark"
|
||||||
: (siteLogo && siteLogo.trim() !== "" ? siteLogo : siteLogoDark);
|
? siteLogoDark && siteLogoDark.trim() !== ""
|
||||||
|
? siteLogoDark
|
||||||
|
: siteLogo
|
||||||
|
: siteLogo && siteLogo.trim() !== ""
|
||||||
|
? siteLogo
|
||||||
|
: siteLogoDark;
|
||||||
|
|
||||||
const getShortLabel = (label: string) => {
|
const getShortLabel = (label: string) => {
|
||||||
const trimmed = label.trim();
|
const trimmed = label.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) {
|
||||||
if (trimmed === "AI精选") return "精选";
|
return "";
|
||||||
if (trimmed === "功能教程") return "教程";
|
}
|
||||||
if (trimmed === "精选榜") return "榜单";
|
if (trimmed === "AI精选") {
|
||||||
if (trimmed === "图片编辑") return "编辑";
|
return "精选";
|
||||||
if (trimmed === "智能体") return "智能";
|
}
|
||||||
if (trimmed === "视频画布") return "视频";
|
if (trimmed === "功能教程") {
|
||||||
if (trimmed === "无限画布") return "画布";
|
return "教程";
|
||||||
if (trimmed === "我的图册") return "图册";
|
}
|
||||||
if (trimmed === "商品策划") return "策划";
|
if (trimmed === "精选榜") {
|
||||||
if (trimmed === "系统设置") return "设置";
|
return "榜单";
|
||||||
if (trimmed === "工具箱") return "工具";
|
}
|
||||||
if (trimmed === "工具箱2") 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("");
|
return Array.from(trimmed).slice(0, 2).join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItemBaseClass =
|
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";
|
"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 = () => {
|
const handleOpenImageEditor = () => {
|
||||||
@ -186,7 +240,7 @@ export function TopNavbar({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
|
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem("pending-edited-image", finalUrl);
|
sessionStorage.setItem("pending-edited-image", finalUrl);
|
||||||
@ -210,11 +264,11 @@ export function TopNavbar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navContent = (
|
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 + 网站名称 */}
|
{/* 顶部:LOGO + 网站名称 */}
|
||||||
<a
|
<a
|
||||||
href="/"
|
|
||||||
className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80"
|
className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
|
href="/"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
@ -222,101 +276,129 @@ export function TopNavbar({
|
|||||||
>
|
>
|
||||||
{displayLogo && (
|
{displayLogo && (
|
||||||
<img
|
<img
|
||||||
src={displayLogo}
|
|
||||||
alt={siteName}
|
alt={siteName}
|
||||||
className="h-7 w-7 object-contain"
|
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>
|
</a>
|
||||||
|
|
||||||
{/* 侧边功能区 */}
|
{/* 侧边功能区 */}
|
||||||
<div className="mt-12 flex w-full flex-1 flex-col items-center gap-3 overflow-y-auto px-2 pb-4">
|
<div className="mt-12 flex w-full flex-1 flex-col items-center gap-3 overflow-y-auto px-2 pb-4">
|
||||||
{/* 工具箱1 (如果启用且有链接) */}
|
{/* 工具箱1 (如果启用且有链接) */}
|
||||||
{toolbox1Enabled && customLinks.filter(l => (l.toolboxGroup || 1) === 1).length > 0 && (
|
{toolbox1Enabled &&
|
||||||
<DropdownMenu>
|
customLinks.filter((l) => (l.toolboxGroup || 1) === 1).length > 0 && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
className={`${navItemBaseClass} p-0`}
|
||||||
className={`${navItemBaseClass} p-0`}
|
size="sm"
|
||||||
title={toolbox1Name}
|
title={toolbox1Name}
|
||||||
type="button"
|
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" />
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<span className={navLabelClass}>{getShortLabel(toolbox1Name)}</span>
|
{customLinks
|
||||||
</Button>
|
.filter((l) => (l.toolboxGroup || 1) === 1)
|
||||||
</DropdownMenuTrigger>
|
.sort((a, b) => a.order - b.order)
|
||||||
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
|
.map((link) => (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<DropdownMenuItem
|
||||||
{customLinks
|
asChild
|
||||||
.filter(l => (l.toolboxGroup || 1) === 1)
|
className="cursor-pointer"
|
||||||
.sort((a, b) => a.order - b.order)
|
key={link.id}
|
||||||
.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"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-5 w-5 opacity-70" />
|
<a
|
||||||
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
|
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"
|
||||||
</a>
|
href={link.url}
|
||||||
</DropdownMenuItem>
|
rel="noopener noreferrer"
|
||||||
))}
|
target="_blank"
|
||||||
</div>
|
>
|
||||||
</DropdownMenuContent>
|
<ExternalLink className="h-5 w-5 opacity-70" />
|
||||||
</DropdownMenu>
|
<span className="line-clamp-2 font-medium text-xs">
|
||||||
)}
|
{link.name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 工具箱2 (如果启用且有链接) */}
|
{/* 工具箱2 (如果启用且有链接) */}
|
||||||
{toolbox2Enabled && customLinks.filter(l => l.toolboxGroup === 2).length > 0 && (
|
{toolbox2Enabled &&
|
||||||
<DropdownMenu>
|
customLinks.filter((l) => l.toolboxGroup === 2).length > 0 && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
className={`${navItemBaseClass} p-0`}
|
||||||
className={`${navItemBaseClass} p-0`}
|
size="sm"
|
||||||
title={toolbox2Name}
|
title={toolbox2Name}
|
||||||
type="button"
|
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" />
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<span className={navLabelClass}>{getShortLabel(toolbox2Name)}</span>
|
{customLinks
|
||||||
</Button>
|
.filter((l) => l.toolboxGroup === 2)
|
||||||
</DropdownMenuTrigger>
|
.sort((a, b) => a.order - b.order)
|
||||||
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
|
.map((link) => (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<DropdownMenuItem
|
||||||
{customLinks
|
asChild
|
||||||
.filter(l => l.toolboxGroup === 2)
|
className="cursor-pointer"
|
||||||
.sort((a, b) => a.order - b.order)
|
key={link.id}
|
||||||
.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"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-5 w-5 opacity-70" />
|
<a
|
||||||
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
|
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"
|
||||||
</a>
|
href={link.url}
|
||||||
</DropdownMenuItem>
|
rel="noopener noreferrer"
|
||||||
))}
|
target="_blank"
|
||||||
</div>
|
>
|
||||||
</DropdownMenuContent>
|
<ExternalLink className="h-5 w-5 opacity-70" />
|
||||||
</DropdownMenu>
|
<span className="line-clamp-2 font-medium text-xs">
|
||||||
)}
|
{link.name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AI智能精选 */}
|
{/* AI智能精选 */}
|
||||||
<a
|
<a
|
||||||
href="/featured"
|
|
||||||
className={navItemBaseClass}
|
className={navItemBaseClass}
|
||||||
title="AI精选"
|
href="/featured"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = "/featured";
|
window.location.href = "/featured";
|
||||||
}}
|
}}
|
||||||
|
title="AI精选"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-5 w-5" />
|
<Sparkles className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("AI精选")}</span>
|
<span className={navLabelClass}>{getShortLabel("AI精选")}</span>
|
||||||
@ -324,68 +406,94 @@ export function TopNavbar({
|
|||||||
|
|
||||||
{productPlanningEnabled && (
|
{productPlanningEnabled && (
|
||||||
<a
|
<a
|
||||||
href="/product-planning"
|
|
||||||
className={navItemBaseClass}
|
className={navItemBaseClass}
|
||||||
title="商品策划"
|
href="/product-planning"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = "/product-planning";
|
window.location.href = "/product-planning";
|
||||||
}}
|
}}
|
||||||
|
title="商品策划"
|
||||||
>
|
>
|
||||||
<BarChart3 className="h-5 w-5" />
|
<BarChart3 className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("商品策划")}</span>
|
<span className={navLabelClass}>{getShortLabel("商品策划")}</span>
|
||||||
</a>
|
</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 && (
|
{tutorialEnabled && tutorialVideos.length > 0 && (
|
||||||
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>
|
<DropdownMenu
|
||||||
|
onOpenChange={setTutorialMenuOpen}
|
||||||
|
open={tutorialMenuOpen}
|
||||||
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`${navItemBaseClass} p-0`}
|
className={`${navItemBaseClass} p-0`}
|
||||||
type="button"
|
size="sm"
|
||||||
title="功能教程"
|
title="功能教程"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<PlayCircle className="h-5 w-5" />
|
<PlayCircle className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("功能教程")}</span>
|
<span className={navLabelClass}>
|
||||||
|
{getShortLabel("功能教程")}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
|
<DropdownMenuContent
|
||||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
align="center"
|
||||||
|
className="w-[320px] p-2"
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
<div className="max-h-[400px] space-y-1 overflow-y-auto">
|
||||||
{tutorialVideos
|
{tutorialVideos
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((video) => (
|
.map((video) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={video.id}
|
|
||||||
className="cursor-pointer p-0"
|
className="cursor-pointer p-0"
|
||||||
|
key={video.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentTutorialVideo(video);
|
setCurrentTutorialVideo(video);
|
||||||
setShowTutorialModal(true);
|
setShowTutorialModal(true);
|
||||||
setTutorialMenuOpen(false);
|
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 ? (
|
{video.coverUrl ? (
|
||||||
<img
|
<img
|
||||||
src={video.coverUrl}
|
alt={video.title}
|
||||||
alt={video.title}
|
className="h-full w-full object-cover"
|
||||||
className="w-full h-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" />
|
<PlayCircle className="h-5 w-5 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 标题和描述 */}
|
{/* 标题和描述 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium text-sm truncate">{video.title}</div>
|
<div className="truncate font-medium text-sm">
|
||||||
|
{video.title}
|
||||||
|
</div>
|
||||||
{video.description && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@ -398,53 +506,66 @@ export function TopNavbar({
|
|||||||
|
|
||||||
{/* 精选榜单 */}
|
{/* 精选榜单 */}
|
||||||
{rankingEnabled && (
|
{rankingEnabled && (
|
||||||
<DropdownMenu open={rankingOpen} onOpenChange={setRankingOpen}>
|
<DropdownMenu onOpenChange={setRankingOpen} open={rankingOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`${navItemBaseClass} p-0`}
|
className={`${navItemBaseClass} p-0`}
|
||||||
type="button"
|
size="sm"
|
||||||
title="精选榜单"
|
title="精选榜单"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Trophy className="h-5 w-5" />
|
<Trophy className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("精选榜")}</span>
|
<span className={navLabelClass}>{getShortLabel("精选榜")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="right" align="center" className="w-[280px] p-3">
|
<DropdownMenuContent
|
||||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
|
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" />
|
<Trophy className="h-5 w-5 text-orange-500" />
|
||||||
<span className="font-semibold">精选榜单 TOP5</span>
|
<span className="font-semibold">精选榜单 TOP5</span>
|
||||||
</div>
|
</div>
|
||||||
{ranking.length === 0 ? (
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{ranking.map((item) => (
|
{ranking.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.userId}
|
className={`flex items-center gap-3 rounded-lg p-2 transition-colors ${
|
||||||
className={`flex items-center gap-3 p-2 rounded-lg transition-colors ${
|
item.rank === 1
|
||||||
item.rank === 1 ? "bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20" :
|
? "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 === 2
|
||||||
item.rank === 3 ? "bg-amber-50 dark:bg-amber-900/20" :
|
? "bg-gray-50 dark:bg-gray-800/50"
|
||||||
"hover:bg-muted"
|
: 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)}
|
{getRankIcon(item.rank)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-sm truncate">{item.userName}</p>
|
<p className="truncate font-medium text-sm">
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1 truncate">
|
{item.userName}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-1 truncate text-muted-foreground text-xs">
|
||||||
<Building2 className="h-3 w-3" />
|
<Building2 className="h-3 w-3" />
|
||||||
{item.department}
|
{item.department}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-lg font-bold text-orange-500">{item.featuredCount}</span>
|
<span className="font-bold text-lg text-orange-500">
|
||||||
<span className="text-xs text-muted-foreground ml-1">精选</span>
|
{item.featuredCount}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-muted-foreground text-xs">
|
||||||
|
精选
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -456,12 +577,12 @@ export function TopNavbar({
|
|||||||
|
|
||||||
{/* 图片编辑 */}
|
{/* 图片编辑 */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`${navItemBaseClass} p-0`}
|
className={`${navItemBaseClass} p-0`}
|
||||||
onClick={handleOpenImageEditor}
|
onClick={handleOpenImageEditor}
|
||||||
type="button"
|
size="sm"
|
||||||
title="图片编辑"
|
title="图片编辑"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Edit className="h-5 w-5" />
|
<Edit className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("图片编辑")}</span>
|
<span className={navLabelClass}>{getShortLabel("图片编辑")}</span>
|
||||||
@ -474,8 +595,8 @@ export function TopNavbar({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = "/canvas?mode=agent";
|
window.location.href = "/canvas?mode=agent";
|
||||||
}}
|
}}
|
||||||
type="button"
|
|
||||||
title="智能体"
|
title="智能体"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Wand2 className="h-5 w-5" />
|
<Wand2 className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("智能体")}</span>
|
<span className={navLabelClass}>{getShortLabel("智能体")}</span>
|
||||||
@ -487,8 +608,8 @@ export function TopNavbar({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = "/video-canvas";
|
window.location.href = "/video-canvas";
|
||||||
}}
|
}}
|
||||||
type="button"
|
|
||||||
title="视频画布"
|
title="视频画布"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Clapperboard className="h-5 w-5" />
|
<Clapperboard className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
|
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
|
||||||
@ -501,8 +622,8 @@ export function TopNavbar({
|
|||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
window.location.href = `/canvas/${newId}`;
|
window.location.href = `/canvas/${newId}`;
|
||||||
}}
|
}}
|
||||||
type="button"
|
|
||||||
title="无限画布"
|
title="无限画布"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-5 w-5" />
|
<LayoutGrid className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("无限画布")}</span>
|
<span className={navLabelClass}>{getShortLabel("无限画布")}</span>
|
||||||
@ -510,13 +631,13 @@ export function TopNavbar({
|
|||||||
|
|
||||||
{/* 我的图册 */}
|
{/* 我的图册 */}
|
||||||
<a
|
<a
|
||||||
href="/gallery"
|
|
||||||
className={navItemBaseClass}
|
className={navItemBaseClass}
|
||||||
title="我的图册"
|
href="/gallery"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = "/gallery";
|
window.location.href = "/gallery";
|
||||||
}}
|
}}
|
||||||
|
title="我的图册"
|
||||||
>
|
>
|
||||||
<Images className="h-5 w-5" />
|
<Images className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("我的图册")}</span>
|
<span className={navLabelClass}>{getShortLabel("我的图册")}</span>
|
||||||
@ -527,13 +648,13 @@ export function TopNavbar({
|
|||||||
{(isAdmin || settingsHref) && (
|
{(isAdmin || settingsHref) && (
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
<a
|
<a
|
||||||
href={settingsHref || "/settings"}
|
|
||||||
className={navItemBaseClass}
|
className={navItemBaseClass}
|
||||||
title="系统设置"
|
href={settingsHref || "/settings"}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = settingsHref || "/settings";
|
window.location.href = settingsHref || "/settings";
|
||||||
}}
|
}}
|
||||||
|
title="系统设置"
|
||||||
>
|
>
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
<span className={navLabelClass}>{getShortLabel("系统设置")}</span>
|
<span className={navLabelClass}>{getShortLabel("系统设置")}</span>
|
||||||
@ -545,94 +666,102 @@ export function TopNavbar({
|
|||||||
{showImageEditor && (
|
{showImageEditor && (
|
||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
imageUrl={editorImageUrl}
|
imageUrl={editorImageUrl}
|
||||||
|
onCancel={handleImageEditorCancel}
|
||||||
onConfirm={handleImageEditorConfirm}
|
onConfirm={handleImageEditorConfirm}
|
||||||
submitMode="publish-new-canvas"
|
submitMode="publish-new-canvas"
|
||||||
onCancel={handleImageEditorCancel}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 使用 Portal 将视频弹窗渲染到 body,确保它在最顶层且不受 nav 的限制
|
// 使用 Portal 将视频弹窗渲染到 body,确保它在最顶层且不受 nav 的限制
|
||||||
const videoModal = showTutorialModal && currentTutorialVideo && typeof document !== 'undefined' ? createPortal(
|
const videoModal =
|
||||||
<div
|
showTutorialModal && currentTutorialVideo && typeof document !== "undefined"
|
||||||
className="fixed inset-0 z-[99999] bg-black/95"
|
? createPortal(
|
||||||
style={{
|
<div
|
||||||
position: 'fixed',
|
className="fixed inset-0 z-[99999] bg-black/95"
|
||||||
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
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowTutorialModal(false);
|
setShowTutorialModal(false);
|
||||||
setCurrentTutorialVideo(null);
|
setCurrentTutorialVideo(null);
|
||||||
}}
|
}}
|
||||||
className="text-white hover:text-red-400 transition-colors z-10 p-1"
|
style={{
|
||||||
title="关闭 (ESC)"
|
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" />
|
<div
|
||||||
</button>
|
className="relative w-full max-w-[1200px]"
|
||||||
</div>
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxHeight: "calc(100vh - 80px)" }}
|
||||||
{/* 视频播放器 */}
|
>
|
||||||
{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">
|
<div className="mb-3 flex items-center justify-between px-2">
|
||||||
<iframe
|
<div className="w-8" /> {/* 占位符保持标题居中 */}
|
||||||
src={`${currentTutorialVideo.videoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}?autoplay=1&rel=0&modestbranding=1`}
|
<h3 className="flex-1 truncate text-center font-medium text-lg text-white">
|
||||||
className="w-full h-full"
|
{currentTutorialVideo.title}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
</h3>
|
||||||
allowFullScreen
|
<button
|
||||||
loading="eager"
|
className="z-10 p-1 text-white transition-colors hover:text-red-400"
|
||||||
/>
|
onClick={() => {
|
||||||
</div>
|
setShowTutorialModal(false);
|
||||||
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
|
setCurrentTutorialVideo(null);
|
||||||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
|
}}
|
||||||
<iframe
|
title="关闭 (ESC)"
|
||||||
src={`${currentTutorialVideo.videoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}&autoplay=1&high_quality=1`}
|
>
|
||||||
className="w-full h-full"
|
<X className="h-8 w-8" />
|
||||||
allowFullScreen
|
</button>
|
||||||
loading="eager"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<VideoPlayer
|
|
||||||
src={currentTutorialVideo.videoUrl}
|
|
||||||
poster={currentTutorialVideo.coverUrl || undefined}
|
|
||||||
title={currentTutorialVideo.title}
|
|
||||||
autoPlay={true}
|
|
||||||
className="shadow-2xl"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 视频描述 */}
|
{/* 视频播放器 */}
|
||||||
{currentTutorialVideo.description && (
|
{currentTutorialVideo.videoUrl.includes("youtube.com") ||
|
||||||
<p className="text-gray-300 text-sm mt-3 text-center">{currentTutorialVideo.description}</p>
|
currentTutorialVideo.videoUrl.includes("youtu.be") ? (
|
||||||
)}
|
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black shadow-2xl">
|
||||||
</div>
|
<iframe
|
||||||
</div>,
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
document.body
|
allowFullScreen
|
||||||
) : null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
|||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
"worldlabs-3d": "https://api.worldlabs.ai",
|
"worldlabs-3d": "https://api.worldlabs.ai",
|
||||||
|
"kittycad-cad": "https://api.zoo.dev",
|
||||||
runway: "https://api.dev.runwayml.com",
|
runway: "https://api.dev.runwayml.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ const providerBaseUrlDefaults = [
|
|||||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||||
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
||||||
{ type: "worldlabs-3d", baseUrl: "https://api.worldlabs.ai" },
|
{ type: "worldlabs-3d", baseUrl: "https://api.worldlabs.ai" },
|
||||||
|
{ type: "kittycad-cad", baseUrl: "https://api.zoo.dev" },
|
||||||
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
|
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -94,7 +95,7 @@ const runMigrate = async () => {
|
|||||||
ADD CONSTRAINT "AiProvider_type_check"
|
ADD CONSTRAINT "AiProvider_type_check"
|
||||||
CHECK (type IN (
|
CHECK (type IN (
|
||||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
'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`
|
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,
|
"when": 1775342400000,
|
||||||
"tag": "0064_add_model3d_asset_share",
|
"tag": "0064_add_model3d_asset_share",
|
||||||
"breakpoints": true
|
"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,
|
type User,
|
||||||
user,
|
user,
|
||||||
vote,
|
vote,
|
||||||
|
aiCadWorkspace,
|
||||||
|
type AiCadWorkspace,
|
||||||
aiProvider,
|
aiProvider,
|
||||||
type AiProvider,
|
type AiProvider,
|
||||||
aiModel,
|
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: {
|
export async function upsertConversationWorkingState(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|||||||
@ -264,7 +264,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
|||||||
export const aiProvider = pgTable("AiProvider", {
|
export const aiProvider = pgTable("AiProvider", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
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()
|
.notNull()
|
||||||
.default("custom"), // 提供商类型
|
.default("custom"), // 提供商类型
|
||||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||||
@ -907,6 +907,32 @@ export const conversationWorkingState = pgTable("ConversationWorkingState", {
|
|||||||
|
|
||||||
export type ConversationWorkingState = InferSelectModel<typeof 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", {
|
export const messageTargetResolution = pgTable("MessageTargetResolution", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
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