fix(canvas): refine toolbar labels/style and harden Tripo API key handling
This commit is contained in:
parent
4349c1cd5f
commit
c1dee85366
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react";
|
||||
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -816,12 +816,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
const getToolbarPosition = () => {
|
||||
if (containerRef.current && typeof window !== "undefined") {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const toolbarHeight = 40;
|
||||
const titleHeight = 28;
|
||||
const gap = 10;
|
||||
let top = rect.top - toolbarHeight - titleHeight - gap;
|
||||
const toolbarHeight = 48;
|
||||
const titleSafeHeight = 34;
|
||||
const gap = 12;
|
||||
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
|
||||
if (top < 12) {
|
||||
top = rect.top + 8;
|
||||
const maxTop = Math.max(12, window.innerHeight - toolbarHeight - 12);
|
||||
top = Math.min(rect.bottom + 10, maxTop);
|
||||
}
|
||||
|
||||
const minLeft = 80;
|
||||
@ -1940,7 +1941,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
调尺
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('angle')}>
|
||||
<Move3D className="h-4 w-4 mr-2" />
|
||||
<Box className="h-4 w-4 mr-2" />
|
||||
视角
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
|
||||
@ -2058,17 +2059,18 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
style={{
|
||||
top: getToolbarPosition().top,
|
||||
left: getToolbarPosition().left,
|
||||
transform: 'translateX(-50%)',
|
||||
transform: 'translateX(-50%) scale(0.8)',
|
||||
transformOrigin: 'top center',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mr-1 inline-flex h-10 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white dark:bg-slate-100 dark:text-slate-900">
|
||||
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white dark:bg-slate-100 dark:text-slate-900">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>快捷编辑</span>
|
||||
<span className="text-white/55 dark:text-slate-500">Tab</span>
|
||||
<span className="whitespace-nowrap">快捷编辑</span>
|
||||
<span className="whitespace-nowrap text-white/55 dark:text-slate-500">Tab</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
@ -2080,7 +2082,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
onClick={() => handleAction('enhance')}
|
||||
>
|
||||
<span className="mr-1.5 rounded-md border border-slate-300 px-1.5 text-[11px] leading-5 text-slate-600 dark:border-slate-500 dark:text-slate-100">HD</span>
|
||||
放大
|
||||
高清增强
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@ -2090,17 +2092,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
onClick={() => handleAction('rmbg')}
|
||||
>
|
||||
<Scissors className="mr-1.5 h-4 w-4" />
|
||||
去背景
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isProcessing}
|
||||
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||||
onClick={() => handleAction('scene-fusion')}
|
||||
>
|
||||
<Sparkles className="mr-1.5 h-4 w-4" />
|
||||
Mockup
|
||||
抠图
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@ -2110,7 +2102,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
onClick={() => handleAction('watermark')}
|
||||
>
|
||||
<Eraser className="mr-1.5 h-4 w-4" />
|
||||
橡皮工具
|
||||
去水印
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@ -2120,7 +2112,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
onClick={() => handleAction('resize')}
|
||||
>
|
||||
<Maximize2 className="mr-1.5 h-4 w-4" />
|
||||
编辑元素
|
||||
尺寸调整
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@ -2130,7 +2122,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
onClick={() => handleAction('text-edit')}
|
||||
>
|
||||
<Type className="mr-1.5 h-4 w-4" />
|
||||
编辑文字
|
||||
文字替换
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@ -2139,8 +2131,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||||
onClick={() => handleAction('angle')}
|
||||
>
|
||||
<Move3D className="mr-1.5 h-4 w-4" />
|
||||
多角度
|
||||
<Box className="mr-1.5 h-4 w-4" />
|
||||
视角生成
|
||||
</Button>
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
@ -2156,7 +2148,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" side="bottom">
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
side="bottom"
|
||||
className="bg-white opacity-100 backdrop-blur-none dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleAction('extend')}>
|
||||
<Expand className="mr-2 h-4 w-4" />
|
||||
扩图
|
||||
|
||||
@ -42,6 +42,20 @@ function normalizeBaseUrl(baseUrl?: string) {
|
||||
return resolved.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function sanitizeHeaderText(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/[\u2028\u2029]/g, "")
|
||||
.replace(/[\u0000-\u001F\u007F]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasNonByteStringChar(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (value.charCodeAt(i) > 255) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
@ -59,7 +73,23 @@ export async function tripoRequest<T = unknown>(
|
||||
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
|
||||
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set("Authorization", `Bearer ${config.apiKey}`);
|
||||
|
||||
const normalizedApiKey = sanitizeHeaderText(String(config.apiKey || "")).replace(/\s+/g, "");
|
||||
if (!normalizedApiKey) {
|
||||
throw new TripoApiError({
|
||||
message: "Tripo3D API Key 为空,请在本地AI设置中重新保存",
|
||||
httpStatus: 400,
|
||||
});
|
||||
}
|
||||
if (hasNonByteStringChar(normalizedApiKey)) {
|
||||
throw new TripoApiError({
|
||||
message:
|
||||
"Tripo3D API Key 含非法字符(可能是复制时带入的隐藏换行符),请在本地AI设置中重新粘贴保存",
|
||||
httpStatus: 400,
|
||||
});
|
||||
}
|
||||
|
||||
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
|
||||
@ -5,27 +5,42 @@ import type { TripoProviderConfig } from "./types";
|
||||
|
||||
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||
|
||||
function sanitizeTripoApiKey(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/[\u2028\u2029]/g, "")
|
||||
.replace(/[\u0000-\u001F\u007F]/g, "")
|
||||
.replace(/\s+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeTripoBaseUrl(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/[\u2028\u2029]/g, "")
|
||||
.replace(/[\u0000-\u001F\u007F]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function getTripoProviderConfig(): Promise<TripoProviderConfig> {
|
||||
const providers = await getEnabledAiProviders();
|
||||
const tripoProvider = providers.find((item) => item.type === "tripo-3d");
|
||||
|
||||
if (tripoProvider?.apiKey?.trim()) {
|
||||
const providerApiKey = sanitizeTripoApiKey(String(tripoProvider?.apiKey || ""));
|
||||
if (tripoProvider && providerApiKey) {
|
||||
return {
|
||||
apiKey: tripoProvider.apiKey.trim(),
|
||||
baseUrl: String(tripoProvider.baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL,
|
||||
apiKey: providerApiKey,
|
||||
baseUrl: sanitizeTripoBaseUrl(String(tripoProvider?.baseUrl || "")) || DEFAULT_TRIPO_BASE_URL,
|
||||
providerId: tripoProvider.id,
|
||||
providerName: tripoProvider.name,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = String(process.env.TRIPO_API_KEY || "").trim();
|
||||
const envApiKey = sanitizeTripoApiKey(String(process.env.TRIPO_API_KEY || ""));
|
||||
if (!envApiKey) {
|
||||
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: envApiKey,
|
||||
baseUrl: String(process.env.TRIPO_BASE_URL || "").trim() || DEFAULT_TRIPO_BASE_URL,
|
||||
baseUrl: sanitizeTripoBaseUrl(String(process.env.TRIPO_BASE_URL || "")) || DEFAULT_TRIPO_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user