From 28c3b0e62fcf2d268632d65a626fbac5e453d869 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 1 May 2026 22:39:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(model3d):=20Tencent=20Hunyuan=203D=20provi?= =?UTF-8?q?der=20=E2=80=94=20Pro/Rapid=20action=20routing=20+=20=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mate/system/model/SystemSettingsDTO.java | 6 + .../system/service/SystemSettingService.java | 22 ++ .../mate/tool/model3d/Model3dCapability.java | 16 + .../tool/model3d/Model3dFileDownloader.java | 70 ++++ .../tool/model3d/Model3dGenerateTool.java | 62 +++ .../model3d/Model3dGenerationProvider.java | 53 +++ .../model3d/Model3dGenerationRequest.java | 53 +++ .../tool/model3d/Model3dGenerationResult.java | 48 +++ .../model3d/Model3dGenerationService.java | 226 +++++++++++ .../model3d/Model3dProviderCapabilities.java | 55 +++ .../tool/model3d/Model3dProviderRegistry.java | 66 ++++ .../tool/model3d/Model3dSubmitResult.java | 41 ++ .../provider/HunyuanModel3dProvider.java | 352 ++++++++++++++++++ .../provider/TencentCloudV3Signer.java | 151 ++++++++ .../model/MessageContentPart.java | 14 + .../migration/h2/V71__hunyuan_3d_provider.sql | 27 ++ .../h2/V72__hunyuan_3d_model_config.sql | 54 +++ .../mysql/V71__hunyuan_3d_provider.sql | 29 ++ .../mysql/V72__hunyuan_3d_model_config.sql | 25 ++ .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_en.properties | 1 + mateclaw-ui/package.json | 1 + mateclaw-ui/pnpm-lock.yaml | 95 ++++- .../src/components/chat/MessageBubble.vue | 62 ++- mateclaw-ui/src/composables/chat/useChat.ts | 13 +- .../composables/useAuthenticatedAttachment.ts | 14 + mateclaw-ui/src/i18n/locales/en-US.ts | 12 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 14 + mateclaw-ui/src/main.ts | 5 + mateclaw-ui/src/router/index.ts | 6 + mateclaw-ui/src/types/index.ts | 2 +- mateclaw-ui/src/views/Settings/Layout.vue | 6 + .../src/views/Settings/Model3D/index.vue | 139 +++++++ mateclaw-ui/vite.config.ts | 12 +- 34 files changed, 1745 insertions(+), 8 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dCapability.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerateTool.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationRequest.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderCapabilities.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderRegistry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dSubmitResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/HunyuanModel3dProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/TencentCloudV3Signer.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V71__hunyuan_3d_provider.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V72__hunyuan_3d_model_config.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V71__hunyuan_3d_provider.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V72__hunyuan_3d_model_config.sql create mode 100644 mateclaw-ui/src/views/Settings/Model3D/index.vue diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java index 75852890..25b21b8e 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java @@ -114,4 +114,10 @@ public class SystemSettingsDTO { /** 首选音乐 provider: auto / google-lyria / minimax */ private String musicProvider; private Boolean musicFallbackEnabled; + + // ===== 3D 模型生成配置 ===== + private Boolean model3dEnabled; + /** 首选 3D provider: auto / hunyuan-3d */ + private String model3dProvider; + private Boolean model3dFallbackEnabled; } diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 0e89967b..17bcdae0 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -54,6 +54,12 @@ public class SystemSettingService { private static final String MUSIC_ENABLED_KEY = "musicEnabled"; private static final String MUSIC_PROVIDER_KEY = "musicProvider"; private static final String MUSIC_FALLBACK_ENABLED_KEY = "musicFallbackEnabled"; + + // 3D 模型生成配置 keys + private static final String MODEL3D_ENABLED_KEY = "model3dEnabled"; + private static final String MODEL3D_PROVIDER_KEY = "model3dProvider"; + private static final String MODEL3D_FALLBACK_ENABLED_KEY = "model3dFallbackEnabled"; + private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey"; private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl"; private static final String FAL_API_KEY_KEY = "falApiKey"; @@ -133,6 +139,11 @@ public class SystemSettingService { dto.setMusicEnabled(Boolean.parseBoolean(getValue(MUSIC_ENABLED_KEY, "false"))); dto.setMusicProvider(getValue(MUSIC_PROVIDER_KEY, "auto")); dto.setMusicFallbackEnabled(Boolean.parseBoolean(getValue(MUSIC_FALLBACK_ENABLED_KEY, "true"))); + + // 3D 模型生成配置 + dto.setModel3dEnabled(Boolean.parseBoolean(getValue(MODEL3D_ENABLED_KEY, "false"))); + dto.setModel3dProvider(getValue(MODEL3D_PROVIDER_KEY, "auto")); + dto.setModel3dFallbackEnabled(Boolean.parseBoolean(getValue(MODEL3D_FALLBACK_ENABLED_KEY, "true"))); return dto; } @@ -295,6 +306,17 @@ public class SystemSettingService { if (dto.getMusicFallbackEnabled() != null) { saveValue(MUSIC_FALLBACK_ENABLED_KEY, String.valueOf(dto.getMusicFallbackEnabled()), "音乐 Provider 级 Fallback"); } + + // 3D 模型生成配置 + if (dto.getModel3dEnabled() != null) { + saveValue(MODEL3D_ENABLED_KEY, String.valueOf(dto.getModel3dEnabled()), "是否启用 3D 模型生成"); + } + if (dto.getModel3dProvider() != null) { + saveValue(MODEL3D_PROVIDER_KEY, dto.getModel3dProvider(), "3D 模型生成首选 Provider"); + } + if (dto.getModel3dFallbackEnabled() != null) { + saveValue(MODEL3D_FALLBACK_ENABLED_KEY, String.valueOf(dto.getModel3dFallbackEnabled()), "3D Provider 级 Fallback"); + } return getSettings(); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dCapability.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dCapability.java new file mode 100644 index 00000000..69f6ce8b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dCapability.java @@ -0,0 +1,16 @@ +package vip.mate.tool.model3d; + +/** + * 3D model generation capability modes. + */ +public enum Model3dCapability { + + /** Pure text-to-3D — generate a model from a textual prompt only. */ + TEXT_TO_3D, + + /** Image-to-3D — single reference image plus optional text. */ + IMAGE_TO_3D, + + /** Multi-view to 3D — multiple reference images for higher fidelity. */ + MULTI_VIEW_TO_3D +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java new file mode 100644 index 00000000..bd77eddc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java @@ -0,0 +1,70 @@ +package vip.mate.tool.model3d; + +import cn.hutool.http.HttpUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Downloads generated 3D-model assets (.glb / .obj / .fbx) from the provider's + * CDN to local conversation storage. Mirrors {@link vip.mate.tool.video.VideoFileDownloader} + * and {@link vip.mate.tool.image.ImageFileDownloader}. + */ +@Slf4j +@Component +public class Model3dFileDownloader { + + private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + + public Path download(String modelUrl, String conversationId, String taskId, + String preferredExtension) throws IOException { + Path dir = UPLOAD_ROOT.resolve(conversationId); + Files.createDirectories(dir); + + String ext = guessExtension(modelUrl, preferredExtension); + String fileName = "model_" + taskId + ext; + Path targetFile = dir.resolve(fileName); + + log.info("[Model3dDownloader] Downloading 3D model from {} to {}", modelUrl, targetFile); + long size = HttpUtil.downloadFile(modelUrl, targetFile.toFile()); + log.info("[Model3dDownloader] Downloaded {} bytes to {}", size, targetFile); + + return targetFile; + } + + public String toServingUrl(String conversationId, Path localPath) { + return "/api/v1/chat/files/" + conversationId + "/" + localPath.getFileName().toString(); + } + + /** + * Pick a filename extension that matches the actual bytes we'll download. + *

+ * URL extension wins over {@code preferred} — Tencent Pro labels the OBJ + * entry as Type="obj" but the {@code Url} actually points at a + * {@code .zip} bundle (obj + mtl + textures). Saving such a file as + * {@code .obj} would mislead anything downstream (browser, model-viewer) + * into trying to parse zip bytes as OBJ text. + */ + private String guessExtension(String url, String preferred) { + String lower = url.toLowerCase().split("\\?")[0]; + if (lower.endsWith(".glb")) return ".glb"; + if (lower.endsWith(".obj")) return ".obj"; + if (lower.endsWith(".fbx")) return ".fbx"; + if (lower.endsWith(".usdz")) return ".usdz"; + if (lower.endsWith(".zip")) return ".zip"; + if (lower.endsWith(".gltf")) return ".gltf"; + // No URL hint — fall back to the provider-declared format. + if (preferred != null && !preferred.isBlank()) { + String p = preferred.toLowerCase(); + if (p.equals("glb") || p.equals("obj") || p.equals("fbx") + || p.equals("usdz") || p.equals("gltf")) { + return "." + p; + } + } + return ".glb"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerateTool.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerateTool.java new file mode 100644 index 00000000..6835ef90 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerateTool.java @@ -0,0 +1,62 @@ +package vip.mate.tool.model3d; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.tool.ConcurrencyUnsafe; +import vip.mate.tool.builtin.ToolExecutionContext; + +/** + * @Tool wrapper that lets the agent invoke 3D-model generation. + * Mirrors {@link vip.mate.tool.music.MusicGenerateTool} — submit immediately, + * the worker pipeline pushes the resulting model URL via SSE + * {@code async_task_completed} when generation finishes. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class Model3dGenerateTool { + + private final Model3dGenerationService model3dGenerationService; + + @ConcurrencyUnsafe("creates async tasks and persists generated 3D assets; provider rate limits also forbid parallel calls") + @Tool(description = "生成 3D 模型 (.glb)。支持文生 3D 与图生 3D,目前 Provider 为腾讯混元 3D(HY-3D-3.1 / HY-3D-3.0 走 Pro 接口,HY-3D-Express 走 Rapid 极速接口)。任务异步执行(约 1-3 分钟),工具立即返回任务 ID,前端会在生成完成时自动接收 SSE 事件并把 3D 模型推到对话中(带 预览),无需用户手动刷新。") + public String model3d_generate( + @ToolParam(description = "3D 模型描述,如:'一个可爱的卡通机械爪子吉祥物,金属质感,蓝橙色调'") String prompt, + @ToolParam(description = "参考图片 URL(可选,给定后走 image-to-3d 模式)") String imageUrl, + @ToolParam(description = "模型版本:HY-3D-3.1(默认/最高精度)/ HY-3D-3.0 / HY-3D-Express(极速)") String model, + @ToolParam(description = "是否生成纹理,默认 true(false 走 GenerateType=Geometry 白模,仅 Pro 支持)") Boolean enableTexture, + @ToolParam(description = "是否生成 PBR 材质(更逼真,但更慢),默认 false。仅 Pro 接口支持(HY-3D-Express 忽略)") Boolean enablePbr, + @Nullable ToolContext ctx) { + + String conversationId = ToolExecutionContext.conversationId(ctx); + if (conversationId == null) { + return "无法获取会话 ID"; + } + String username = ToolExecutionContext.username(ctx); + + Model3dGenerationRequest request = Model3dGenerationRequest.builder() + .prompt(prompt) + .imageUrl(imageUrl == null || imageUrl.isBlank() ? null : imageUrl) + .model(model == null || model.isBlank() ? null : model) + // outputFormat is currently fixed to glb upstream — keep field on + // the request so the service-layer normalize step still runs. + .outputFormat("glb") + .enableTexture(enableTexture == null ? Boolean.TRUE : enableTexture) + .enablePbr(enablePbr == null ? Boolean.FALSE : enablePbr) + .build(); + + Model3dGenerationResult result = model3dGenerationService.submitGeneration( + request, conversationId, username != null ? username : "system"); + + if (result.isSubmitted()) { + return result.getMessage(); + } else { + return "3D 模型生成失败:" + result.getMessage(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationProvider.java new file mode 100644 index 00000000..1da34076 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationProvider.java @@ -0,0 +1,53 @@ +package vip.mate.tool.model3d; + +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.task.AsyncTaskService.TaskPollResult; + +import java.util.Set; + +/** + * 3D-model generation provider. Mirrors + * {@link vip.mate.tool.video.VideoGenerationProvider} — submit returns a task id, + * checkStatus polls for terminal state. + */ +public interface Model3dGenerationProvider { + + /** Unique provider id, e.g. {@code "hunyuan-3d"}. */ + String id(); + + /** Display name. */ + String label(); + + /** Whether the provider needs an API key. */ + boolean requiresCredential(); + + /** Auto-detect ranking — lower wins when no provider is explicitly chosen. */ + int autoDetectOrder(); + + /** Modes the provider supports. */ + Set capabilities(); + + /** Detailed capability declaration (formats, models, etc.). */ + Model3dProviderCapabilities detailedCapabilities(); + + /** Whether the provider is currently usable (credentials configured, etc.). */ + boolean isAvailable(SystemSettingsDTO config); + + /** + * Submit a 3D-model generation job (async, non-blocking). + * + * @param request unified request + * @param config system configuration + * @return submit result with provider task id + */ + Model3dSubmitResult submit(Model3dGenerationRequest request, SystemSettingsDTO config); + + /** + * Poll the provider for the current job state. + * + * @param providerTaskId provider-issued task id from {@link #submit} + * @param config system configuration + * @return poll result; {@code null} signals "no change yet, retry later" + */ + TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config); +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationRequest.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationRequest.java new file mode 100644 index 00000000..066e841c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationRequest.java @@ -0,0 +1,53 @@ +package vip.mate.tool.model3d; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * Unified 3D-model generation request. Mirrors the shape of + * {@link vip.mate.tool.video.VideoGenerationRequest} and + * {@link vip.mate.tool.image.ImageGenerationRequest}. + */ +@Data +@Builder +public class Model3dGenerationRequest { + + /** Text prompt describing the desired 3D model (object name, style, materials). */ + private String prompt; + + /** Generation mode. Inferred from inputs when null. */ + private Model3dCapability mode; + + /** Provider-specific model id (provider has a default). */ + private String model; + + /** + * Single reference image URL for {@link Model3dCapability#IMAGE_TO_3D}. + * Mutually exclusive with {@link #imageUrls}. + */ + private String imageUrl; + + /** + * Multi-view reference image URLs for {@link Model3dCapability#MULTI_VIEW_TO_3D}. + * Typically 4 views (front / left / right / back). + */ + private List imageUrls; + + /** Output format: glb / obj / fbx. Provider may not support all. */ + @Builder.Default + private String outputFormat = "glb"; + + /** Whether to bake textures into the mesh. */ + @Builder.Default + private Boolean enableTexture = true; + + /** Whether to generate physically-based rendering (PBR) materials. */ + @Builder.Default + private Boolean enablePbr = false; + + /** Provider-specific extra parameters. */ + private Map extraParams; +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationResult.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationResult.java new file mode 100644 index 00000000..fd5c0eaa --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationResult.java @@ -0,0 +1,48 @@ +package vip.mate.tool.model3d; + +import lombok.Builder; +import lombok.Data; + +/** + * Service-layer outcome of {@link Model3dGenerationService#submitGeneration}. + * Mirrors {@link vip.mate.tool.video.VideoGenerationResult}. + */ +@Data +@Builder +public class Model3dGenerationResult { + + /** Internal task id that the agent can use to query status. */ + private String taskId; + + /** Provider that handled the submission. */ + private String providerName; + + /** Status string ("submitted" / "failed"). */ + private String status; + + /** Human-readable text returned to the LLM tool caller. */ + private String message; + + /** Whether submission succeeded. */ + private boolean submitted; + + public static Model3dGenerationResult success(String taskId, String providerName) { + return Model3dGenerationResult.builder() + .taskId(taskId) + .providerName(providerName) + .status("submitted") + .submitted(true) + // Format MUST keep `taskId=...` so the frontend reconnect detector + // (useChat.ts TASK_ID_PATTERNS) can extract it from the tool result. + .message("3D 模型生成任务已提交(taskId=" + taskId + ", provider=" + providerName + ")。预计 1-3 分钟完成,完成后会自动显示在对话中。") + .build(); + } + + public static Model3dGenerationResult failure(String message) { + return Model3dGenerationResult.builder() + .submitted(false) + .status("failed") + .message(message) + .build(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationService.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationService.java new file mode 100644 index 00000000..9fed7eb9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dGenerationService.java @@ -0,0 +1,226 @@ +package vip.mate.tool.model3d; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.system.service.SystemSettingService; +import vip.mate.task.AsyncTaskService; +import vip.mate.task.AsyncTaskService.TaskPollResult; +import vip.mate.task.model.AsyncTaskEntity; +import vip.mate.task.model.AsyncTaskInfo; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 3D-model generation service. Mirrors {@link vip.mate.tool.video.VideoGenerationService}. + *

+ * Provider workflow: submit → AsyncTask polling → on terminal-success download + * the .glb to local storage → write {@code model3d} MessageContentPart → + * broadcast {@code async_task_completed} via the generic data map. + *

+ * Provider-side {@link TaskPollResult} carries the model URL via + * {@code resultJson} (a JSON string with {@code modelUrl} / optional + * {@code format}) — TaskPollResult's structured fields are tied to image/video + * URLs and we don't want to widen the record's positional constructor for a + * single new media kind. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class Model3dGenerationService { + + private final SystemSettingService systemSettingService; + private final Model3dProviderRegistry providerRegistry; + private final AsyncTaskService asyncTaskService; + private final ConversationService conversationService; + private final Model3dFileDownloader fileDownloader; + private final ObjectMapper objectMapper; + + private static final String TASK_TYPE = "model3d_generation"; + + public Model3dGenerationResult submitGeneration(Model3dGenerationRequest request, + String conversationId, + String createdBy) { + SystemSettingsDTO config = systemSettingService.getAllSettings(); + + if (!Boolean.TRUE.equals(config.getModel3dEnabled())) { + return Model3dGenerationResult.failure("3D 模型生成功能未启用,请在系统设置中开启"); + } + + if (request.getMode() == null) { + request.setMode(inferMode(request)); + } + + Model3dGenerationProvider primary = providerRegistry.resolve(config, request.getMode()); + if (primary == null) { + return Model3dGenerationResult.failure( + "没有可用的 3D 生成 Provider,请在系统设置中配置(当前支持腾讯混元 3D)"); + } + + return submitWithFallback(request, config, primary, conversationId, createdBy); + } + + public AsyncTaskInfo checkTaskStatus(String taskId) { + return asyncTaskService.getTaskInfo(taskId); + } + + // ==================== internal ==================== + + private Model3dGenerationResult submitWithFallback(Model3dGenerationRequest request, + SystemSettingsDTO config, + Model3dGenerationProvider primary, + String conversationId, + String createdBy) { + normalizeForProvider(request, primary); + Model3dSubmitResult submitResult = primary.submit(request, config); + if (submitResult.isAccepted()) { + return createAsyncTask(submitResult, request, conversationId, createdBy); + } + + List errors = new ArrayList<>(); + errors.add(primary.id() + ": " + submitResult.getErrorMessage()); + + if (Boolean.TRUE.equals(config.getModel3dFallbackEnabled())) { + for (Model3dGenerationProvider fb : providerRegistry.fallbackCandidates( + config, request.getMode(), primary.id())) { + log.info("[Model3dGen] Trying fallback provider: {}", fb.id()); + normalizeForProvider(request, fb); + submitResult = fb.submit(request, config); + if (submitResult.isAccepted()) { + return createAsyncTask(submitResult, request, conversationId, createdBy); + } + errors.add(fb.id() + ": " + submitResult.getErrorMessage()); + log.warn("[Model3dGen] Fallback {} failed: {}", fb.id(), submitResult.getErrorMessage()); + } + } + + return Model3dGenerationResult.failure( + "所有 Provider 均提交失败\n" + String.join("\n", errors)); + } + + private Model3dGenerationResult createAsyncTask(Model3dSubmitResult submitResult, + Model3dGenerationRequest request, + String conversationId, + String createdBy) { + try { + String requestJson = objectMapper.writeValueAsString(request); + AsyncTaskEntity task = asyncTaskService.createTask( + TASK_TYPE, conversationId, null, + submitResult.getProviderName(), + submitResult.getProviderTaskId(), + requestJson, createdBy); + + Model3dGenerationProvider provider = providerRegistry.getById(submitResult.getProviderName()); + if (provider == null) { + return Model3dGenerationResult.failure("Provider 不存在: " + submitResult.getProviderName()); + } + + asyncTaskService.startPolling( + task.getTaskId(), + providerTaskId -> provider.checkStatus(providerTaskId, systemSettingService.getAllSettings()), + this::handleCompletion); + + return Model3dGenerationResult.success(task.getTaskId(), submitResult.getProviderName()); + } catch (Exception e) { + log.error("[Model3dGen] Failed to create async task: {}", e.getMessage(), e); + return Model3dGenerationResult.failure("创建任务失败: " + e.getMessage()); + } + } + + private void handleCompletion(AsyncTaskEntity task, TaskPollResult result) { + if (!result.succeeded()) { + asyncTaskService.broadcastTaskEventWithData(task, "async_task_completed", + false, Map.of(), result.errorMessage()); + log.warn("[Model3dGen] Task {} failed: {}", task.getTaskId(), result.errorMessage()); + return; + } + + try { + // The provider stuffs {modelUrl, format?} into resultJson. + String resultJson = result.resultJson(); + String modelUrl = null; + String format = null; + if (resultJson != null && !resultJson.isBlank()) { + try { + JsonNode node = objectMapper.readTree(resultJson); + modelUrl = node.path("modelUrl").asText(null); + format = node.path("format").asText(null); + } catch (Exception e) { + log.warn("[Model3dGen] Failed to parse resultJson for task {}: {}", + task.getTaskId(), e.getMessage()); + } + } + if (modelUrl == null || modelUrl.isBlank()) { + log.warn("[Model3dGen] Task {} succeeded but no model URL in resultJson", task.getTaskId()); + asyncTaskService.broadcastTaskEventWithData(task, "async_task_completed", + false, Map.of(), "3D 生成成功但未返回模型 URL"); + return; + } + + Path localPath = fileDownloader.download( + modelUrl, task.getConversationId(), task.getTaskId(), format); + String servingUrl = fileDownloader.toServingUrl(task.getConversationId(), localPath); + + String fileName = localPath.getFileName().toString(); + MessageContentPart modelPart = MessageContentPart.model3d(null, fileName); + modelPart.setFileUrl(servingUrl); + // model/gltf-binary for .glb is the iana-registered MIME; downstream + // only cares about the URL, not the MIME header. + if (fileName.endsWith(".glb")) { + modelPart.setContentType("model/gltf-binary"); + } else if (fileName.endsWith(".obj")) { + modelPart.setContentType("model/obj"); + } else if (fileName.endsWith(".fbx")) { + modelPart.setContentType("model/fbx"); + } else if (fileName.endsWith(".usdz")) { + modelPart.setContentType("model/vnd.usdz+zip"); + } + + conversationService.saveMessage( + task.getConversationId(), "assistant", + "3D 模型已生成完毕", + List.of(modelPart), "completed"); + + Map extra = new LinkedHashMap<>(); + extra.put("modelUrl", servingUrl); + if (format != null) extra.put("format", format); + asyncTaskService.broadcastTaskEventWithData(task, "async_task_completed", + true, extra, null); + + log.info("[Model3dGen] Task {} completed, model saved: {}", task.getTaskId(), servingUrl); + } catch (Exception e) { + log.error("[Model3dGen] Completion handling failed for task {}: {}", + task.getTaskId(), e.getMessage(), e); + asyncTaskService.broadcastTaskEventWithData(task, "async_task_completed", + false, Map.of(), "3D 模型下载或保存失败: " + e.getMessage()); + } + } + + private Model3dCapability inferMode(Model3dGenerationRequest request) { + if (request.getImageUrls() != null && request.getImageUrls().size() > 1) { + return Model3dCapability.MULTI_VIEW_TO_3D; + } + if (request.getImageUrl() != null && !request.getImageUrl().isBlank()) { + return Model3dCapability.IMAGE_TO_3D; + } + if (request.getImageUrls() != null && !request.getImageUrls().isEmpty()) { + return Model3dCapability.IMAGE_TO_3D; + } + return Model3dCapability.TEXT_TO_3D; + } + + private void normalizeForProvider(Model3dGenerationRequest request, Model3dGenerationProvider provider) { + Model3dProviderCapabilities caps = provider.detailedCapabilities(); + if (caps == null) return; + request.setOutputFormat(caps.normalizeFormat(request.getOutputFormat())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderCapabilities.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderCapabilities.java new file mode 100644 index 00000000..42760623 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderCapabilities.java @@ -0,0 +1,55 @@ +package vip.mate.tool.model3d; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Set; + +/** + * Per-provider 3D-model capability declaration. Each provider declares its + * supported modes, output formats, and model catalog so the runtime can + * normalize a request to what the provider accepts. + */ +@Data +@Builder +public class Model3dProviderCapabilities { + + /** Modes the provider can serve. */ + @Builder.Default + private Set modes = Set.of(Model3dCapability.TEXT_TO_3D); + + /** Output formats the provider can return: glb / obj / fbx / usdz. */ + @Builder.Default + private List supportedFormats = List.of("glb"); + + /** Whether the provider supports texture baking. */ + @Builder.Default + private boolean supportsTexture = true; + + /** Whether the provider supports PBR materials. */ + @Builder.Default + private boolean supportsPbr = false; + + /** Default model id. */ + private String defaultModel; + + /** Available model ids on this provider. */ + @Builder.Default + private List models = List.of(); + + /** + * Pick a supported output format closest to the request, falling back to + * the first supported one. + */ + public String normalizeFormat(String requested) { + if (requested == null || requested.isBlank()) { + return supportedFormats.isEmpty() ? "glb" : supportedFormats.get(0); + } + String lower = requested.toLowerCase(); + for (String f : supportedFormats) { + if (f.equalsIgnoreCase(lower)) return f; + } + return supportedFormats.isEmpty() ? "glb" : supportedFormats.get(0); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderRegistry.java new file mode 100644 index 00000000..b94b0fdc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dProviderRegistry.java @@ -0,0 +1,66 @@ +package vip.mate.tool.model3d; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.system.model.SystemSettingsDTO; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 3D-model provider registry. Mirrors {@link vip.mate.tool.music.MusicProviderRegistry} + * — Spring auto-discovers all {@link Model3dGenerationProvider} beans, sorts by + * {@link Model3dGenerationProvider#autoDetectOrder()}, exposes resolution and + * fallback iteration to the service layer. + */ +@Slf4j +@Component +public class Model3dProviderRegistry { + + private final List sortedProviders; + private final Map providerMap; + + public Model3dProviderRegistry(List providers) { + this.sortedProviders = providers.stream() + .sorted(Comparator.comparingInt(Model3dGenerationProvider::autoDetectOrder)) + .toList(); + this.providerMap = providers.stream() + .collect(Collectors.toMap(Model3dGenerationProvider::id, Function.identity())); + log.info("注册 3D 模型生成 Provider {} 个: {}", sortedProviders.size(), + sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList()); + } + + public Model3dGenerationProvider getById(String id) { + return providerMap.get(id); + } + + public Model3dGenerationProvider resolve(SystemSettingsDTO config, Model3dCapability mode) { + String configuredId = config.getModel3dProvider(); + if (configuredId != null && !configuredId.isBlank() && !"auto".equals(configuredId)) { + Model3dGenerationProvider p = providerMap.get(configuredId); + if (p != null && p.isAvailable(config) && supportsMode(p, mode)) return p; + } + for (Model3dGenerationProvider p : sortedProviders) { + if (p.isAvailable(config) && supportsMode(p, mode)) return p; + } + return null; + } + + public List fallbackCandidates(SystemSettingsDTO config, + Model3dCapability mode, + String excludeId) { + return sortedProviders.stream() + .filter(p -> !p.id().equals(excludeId)) + .filter(p -> p.isAvailable(config)) + .filter(p -> supportsMode(p, mode)) + .toList(); + } + + private static boolean supportsMode(Model3dGenerationProvider p, Model3dCapability mode) { + if (mode == null) return true; + return p.capabilities() != null && p.capabilities().contains(mode); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dSubmitResult.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dSubmitResult.java new file mode 100644 index 00000000..42ef328b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dSubmitResult.java @@ -0,0 +1,41 @@ +package vip.mate.tool.model3d; + +import lombok.Builder; +import lombok.Data; + +/** + * Provider-side submission result for a 3D-model generation job. + * Mirrors {@link vip.mate.tool.video.VideoSubmitResult}. + */ +@Data +@Builder +public class Model3dSubmitResult { + + /** Provider-issued task id (used to poll status later). */ + private String providerTaskId; + + /** Provider id ({@code hunyuan-3d}, future expansions). */ + private String providerName; + + /** Whether the provider accepted the submission. */ + private boolean accepted; + + /** Error message, populated only when {@code accepted=false}. */ + private String errorMessage; + + public static Model3dSubmitResult success(String providerTaskId, String providerName) { + return Model3dSubmitResult.builder() + .providerTaskId(providerTaskId) + .providerName(providerName) + .accepted(true) + .build(); + } + + public static Model3dSubmitResult failure(String providerName, String errorMessage) { + return Model3dSubmitResult.builder() + .providerName(providerName) + .accepted(false) + .errorMessage(errorMessage) + .build(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/HunyuanModel3dProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/HunyuanModel3dProvider.java new file mode 100644 index 00000000..b26f32e3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/HunyuanModel3dProvider.java @@ -0,0 +1,352 @@ +package vip.mate.tool.model3d.provider; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.task.AsyncTaskService.TaskPollResult; +import vip.mate.tool.model3d.Model3dCapability; +import vip.mate.tool.model3d.Model3dGenerationProvider; +import vip.mate.tool.model3d.Model3dGenerationRequest; +import vip.mate.tool.model3d.Model3dProviderCapabilities; +import vip.mate.tool.model3d.Model3dSubmitResult; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Tencent Hunyuan 3D Rapid provider — async submit/poll using the + * {@code ai3d} TencentCloud API service (v2025-05-13). + * + *

Credentials

+ * Resolved from {@code mate_model_provider} entry {@code provider_id='hunyuan-3d'}. + * The {@code api_key} column stores {@code "SecretId:SecretKey"} (colon-joined, + * mirrors the existing two-key Kling provider convention). The {@code base_url} + * column may override the API host (default {@code ai3d.tencentcloudapi.com}). + * + *

API surface

+ * + * + *

Result delivery contract

+ * On {@code DONE}, {@link #checkStatus} returns + * {@code TaskPollResult.succeeded(null, null, )} where {@code } is + * {@code {"modelUrl": "", "format": "glb"}}. The + * {@code Model3dGenerationService.handleCompletion} parses that out, downloads + * the asset, and persists a {@code model3d} MessageContentPart. + */ +@Slf4j +@Component +public class HunyuanModel3dProvider implements Model3dGenerationProvider { + + private final ObjectMapper objectMapper; + private final ModelProviderService modelProviderService; + + public HunyuanModel3dProvider(ObjectMapper objectMapper, + ModelProviderService modelProviderService) { + this.objectMapper = objectMapper; + this.modelProviderService = modelProviderService; + } + + private static final String PROVIDER_ID = "hunyuan-3d"; + private static final String SERVICE = "ai3d"; + private static final String VERSION = "2025-05-13"; + private static final String REGION = "ap-guangzhou"; + private static final String DEFAULT_HOST = "ai3d.tencentcloudapi.com"; + + // Two action families exist on the same ai3d service. Model name routes + // the request to the right one — see V72 migration for the model -> action + // mapping. We dispatch QueryHunyuanTo3DRapidJob vs Pro the same way: the + // task entity's `provider_task_id` carries a "rapid:" or "pro:" prefix so + // checkStatus knows which Action to call without re-reading model config. + private static final String SUBMIT_RAPID = "SubmitHunyuanTo3DRapidJob"; + private static final String QUERY_RAPID = "QueryHunyuanTo3DRapidJob"; + private static final String SUBMIT_PRO = "SubmitHunyuanTo3DProJob"; + private static final String QUERY_PRO = "QueryHunyuanTo3DProJob"; + + private static final String DEFAULT_MODEL = "HY-3D-3.1"; + + @Override public String id() { return PROVIDER_ID; } + @Override public String label() { return "Tencent Hunyuan 3D"; } + @Override public boolean requiresCredential() { return true; } + @Override public int autoDetectOrder() { return 100; } + + @Override + public Set capabilities() { + return Set.of(Model3dCapability.TEXT_TO_3D, Model3dCapability.IMAGE_TO_3D); + } + + @Override + public Model3dProviderCapabilities detailedCapabilities() { + return Model3dProviderCapabilities.builder() + .modes(capabilities()) + .supportedFormats(List.of("glb")) + .supportsTexture(true) + // PBR is supported on the Pro action only (HY-3D-3.0 / HY-3D-3.1); + // exposed at the capability level so future routing can negotiate. + .supportsPbr(true) + .defaultModel(DEFAULT_MODEL) + .models(List.of("HY-3D-3.1", "HY-3D-3.0", "HY-3D-Express")) + .build(); + } + + /** Whether the requested model variant goes through the Pro action family. */ + private static boolean usePro(String model) { + if (model == null || model.isBlank()) return true; // default 3.1 -> Pro + String upper = model.toUpperCase(); + if (upper.contains("EXPRESS") || upper.contains("RAPID")) return false; + return true; // HY-3D-3.x -> Pro + } + + @Override + public boolean isAvailable(SystemSettingsDTO config) { + return resolveCredentials() != null; + } + + private record Credentials(String secretId, String secretKey, String host) {} + + private Credentials resolveCredentials() { + try { + if (!modelProviderService.isProviderConfigured(PROVIDER_ID)) { + return null; + } + ModelProviderEntity entity = modelProviderService.getProviderConfig(PROVIDER_ID); + String apiKey = entity.getApiKey(); + if (!StringUtils.hasText(apiKey)) return null; + // api_key column packs both halves as "SecretId:SecretKey" + String[] parts = apiKey.split(":", 2); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + log.warn("[Hunyuan3D] Provider api_key must be \"SecretId:SecretKey\" (colon-joined)"); + return null; + } + String host = StringUtils.hasText(entity.getBaseUrl()) + ? extractHost(entity.getBaseUrl()) + : DEFAULT_HOST; + return new Credentials(parts[0].trim(), parts[1].trim(), host); + } catch (Exception e) { + return null; + } + } + + /** Reduce a configured base_url to bare host (strip scheme + path). */ + private static String extractHost(String baseUrl) { + try { + java.net.URI uri = java.net.URI.create(baseUrl.trim()); + if (uri.getHost() != null) return uri.getHost(); + } catch (Exception ignore) { + // fall through + } + return DEFAULT_HOST; + } + + @Override + public Model3dSubmitResult submit(Model3dGenerationRequest request, SystemSettingsDTO config) { + Credentials creds = resolveCredentials(); + if (creds == null) { + return Model3dSubmitResult.failure(id(), + "Hunyuan 3D 凭据未配置(在「模型与凭据」中以 SecretId:SecretKey 格式保存到 hunyuan-3d)"); + } + try { + boolean pro = usePro(request.getModel()); + ObjectNode body = objectMapper.createObjectNode(); + + // Both Rapid and Pro accept Prompt XOR ImageUrl. Pro additionally + // takes MultiViewImages and several quality knobs (EnablePBR, + // FaceCount, GenerateType). Pro also supports Prompt + ImageUrl + // together when GenerateType="Sketch", but we don't expose Sketch + // mode at the tool layer yet — keep XOR semantics for now. + if (StringUtils.hasText(request.getImageUrl())) { + body.put("ImageUrl", request.getImageUrl()); + } else if (StringUtils.hasText(request.getPrompt())) { + body.put("Prompt", request.getPrompt()); + } else { + return Model3dSubmitResult.failure(id(), + "缺少必要参数:请提供 prompt(文生 3D)或 imageUrl(图生 3D)"); + } + + if (pro) { + // Multi-view: map up to 3 extra views to ViewType={back,left,right}. + // Tencent limits each angle to one image; ordering of imageUrls + // beyond the primary determines the angle slot. + List extraViews = request.getImageUrls(); + if (extraViews != null && !extraViews.isEmpty()) { + String[] viewTypes = {"back", "left", "right"}; + com.fasterxml.jackson.databind.node.ArrayNode arr = body.putArray("MultiViewImages"); + for (int i = 0; i < extraViews.size() && i < viewTypes.length; i++) { + String url = extraViews.get(i); + if (!StringUtils.hasText(url)) continue; + ObjectNode view = arr.addObject(); + view.put("ViewType", viewTypes[i]); + view.put("ViewImageUrl", url); + } + } + if (Boolean.TRUE.equals(request.getEnablePbr())) { + body.put("EnablePBR", true); + } + // White-model toggle: when texture is explicitly disabled, ask + // for the geometry-only generate type. + if (Boolean.FALSE.equals(request.getEnableTexture())) { + body.put("GenerateType", "Geometry"); + } + } + + String action = pro ? SUBMIT_PRO : SUBMIT_RAPID; + JsonNode resp = invoke(creds, action, body.toString()); + JsonNode response = resp.path("Response"); + if (response.has("Error")) { + String code = response.path("Error").path("Code").asText("UnknownError"); + String msg = response.path("Error").path("Message").asText("Unknown error"); + log.warn("[Hunyuan3D] {} failed: {} ({})", action, msg, code); + return Model3dSubmitResult.failure(id(), msg + " (" + code + ")"); + } + String jobId = response.path("JobId").asText(null); + if (!StringUtils.hasText(jobId)) { + return Model3dSubmitResult.failure(id(), "Tencent 未返回 JobId"); + } + // Embed the action family in the providerTaskId so checkStatus + // dispatches to the matching Query action without re-reading the + // model from the request entity (which is JSON-serialized and + // would require an extra DB round-trip). + String taggedJobId = (pro ? "pro:" : "rapid:") + jobId; + log.info("[Hunyuan3D] {} submitted job: {} (model={})", + action, jobId, request.getModel()); + return Model3dSubmitResult.success(taggedJobId, id()); + } catch (Exception e) { + log.error("[Hunyuan3D] Submit error: {}", e.getMessage(), e); + return Model3dSubmitResult.failure(id(), e.getMessage()); + } + } + + @Override + public TaskPollResult checkStatus(String providerTaskId, SystemSettingsDTO config) { + Credentials creds = resolveCredentials(); + if (creds == null) return TaskPollResult.failed("Hunyuan 3D 凭据未配置"); + + try { + // providerTaskId carries a "rapid:" / "pro:" prefix from submit(). + String queryAction; + String rawJobId; + if (providerTaskId != null && providerTaskId.startsWith("pro:")) { + queryAction = QUERY_PRO; + rawJobId = providerTaskId.substring(4); + } else if (providerTaskId != null && providerTaskId.startsWith("rapid:")) { + queryAction = QUERY_RAPID; + rawJobId = providerTaskId.substring(6); + } else { + // Backward-compat for any unprefixed jobs that may still be in + // flight from a previous version: default to Rapid. + queryAction = QUERY_RAPID; + rawJobId = providerTaskId; + } + + ObjectNode body = objectMapper.createObjectNode(); + body.put("JobId", rawJobId); + + JsonNode resp = invoke(creds, queryAction, body.toString()); + JsonNode response = resp.path("Response"); + if (response.has("Error")) { + String msg = response.path("Error").path("Message").asText("Unknown error"); + return TaskPollResult.failed(msg); + } + + String status = response.path("Status").asText(""); + switch (status) { + case "DONE": + PickedFile picked = pickBestResultFile(response); + if (picked == null || !StringUtils.hasText(picked.url())) { + return TaskPollResult.failed("Tencent 任务完成但未返回模型 URL"); + } + // Stuff modelUrl + format into resultJson — the service layer parses it. + ObjectNode result = objectMapper.createObjectNode(); + result.put("modelUrl", picked.url()); + result.put("format", picked.type()); + return TaskPollResult.succeeded(null, null, result.toString()); + case "FAIL": + String errCode = response.path("ErrorCode").asText(null); + String errMsg = response.path("ErrorMessage").asText("任务失败"); + return TaskPollResult.failed(errCode != null + ? errMsg + " (" + errCode + ")" : errMsg); + case "RUN": + return TaskPollResult.running(null); + case "WAIT": + default: + return TaskPollResult.pending(null); + } + } catch (Exception e) { + log.error("[Hunyuan3D] Poll error for job {}: {}", providerTaskId, e.getMessage()); + return null; + } + } + + private record PickedFile(String url, String type) {} + + /** + * Pick the best file from {@code ResultFile3Ds[]} for our pipeline. + *

+ * Tencent Pro returns multiple {@code File3D} entries per job — typically + * one each of GLB / OBJ / FBX (and possibly STL/USDZ). The OBJ entry's + * {@code Url} actually points at a {@code .zip} bundle (obj + textures + + * mtl), which {@code } can't render directly. The GLB entry + * is a single self-contained binary that is renderable. So we + * prefer GLB → FBX → OBJ → first-available, ignoring the URL extension. + */ + private static PickedFile pickBestResultFile(JsonNode response) { + JsonNode files = response.path("ResultFile3Ds"); + if (!files.isArray() || files.isEmpty()) return null; + + PickedFile glb = null, fbx = null, obj = null, anyFile = null; + for (JsonNode f : files) { + String type = f.path("Type").asText("").toLowerCase(); + String url = f.path("Url").asText(null); + if (!StringUtils.hasText(url)) continue; + PickedFile entry = new PickedFile(url, type.isBlank() ? "glb" : type); + if (anyFile == null) anyFile = entry; + switch (type) { + case "glb" -> { if (glb == null) glb = entry; } + case "fbx" -> { if (fbx == null) fbx = entry; } + case "obj" -> { if (obj == null) obj = entry; } + default -> { /* stl / usdz / unknown — only used as last resort */ } + } + } + if (glb != null) return glb; + if (fbx != null) return fbx; + if (obj != null) return obj; + return anyFile; + } + + /** + * Sign + send a TC3 v3 request. Tencent's gateway returns 200 OK with an + * {@code Error} body on logical failure, so we return the parsed + * {@code JsonNode} regardless of status — the caller inspects it. + */ + private JsonNode invoke(Credentials creds, String action, String payload) throws Exception { + TencentCloudV3Signer.SignedHeaders sig = + TencentCloudV3Signer.sign(creds.secretId(), creds.secretKey(), SERVICE, creds.host(), payload); + Map common = TencentCloudV3Signer.commonHeaders(action, VERSION, REGION, sig.timestamp()); + + HttpRequest req = HttpRequest.post("https://" + creds.host()) + .header("Content-Type", "application/json; charset=utf-8") + .header("Host", sig.host()) + .header("Authorization", sig.authorization()) + .body(payload) + .timeout(60_000); + for (Map.Entry e : common.entrySet()) { + req.header(e.getKey(), e.getValue()); + } + try (HttpResponse response = req.execute()) { + return objectMapper.readTree(response.body()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/TencentCloudV3Signer.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/TencentCloudV3Signer.java new file mode 100644 index 00000000..7965e287 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/provider/TencentCloudV3Signer.java @@ -0,0 +1,151 @@ +package vip.mate.tool.model3d.provider; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TimeZone; + +/** + * Tencent Cloud TC3-HMAC-SHA256 v3 request signer. + *

+ * Spec: 签名方法 v3. + * Implements only the JSON / POST flavor we need for the + * {@code ai3d} service. Stateless — each call to {@link #sign} produces a + * fresh {@code Authorization} header for the supplied request. + * + *

Why hand-rolled instead of {@code tencentcloud-sdk-java}: the SDK pulls in + * ~30 MB of dependencies covering 200+ products. We need exactly two endpoints + * on one product, and an isolated 80-line signer keeps the dependency surface + * minimal and testable.

+ */ +public final class TencentCloudV3Signer { + + private TencentCloudV3Signer() {} + + public record SignedHeaders( + String authorization, + String timestamp, + String host + ) {} + + /** + * Compute the {@code Authorization} header for a Tencent Cloud v3 POST request. + * + * @param secretId Tencent Cloud SecretId + * @param secretKey Tencent Cloud SecretKey + * @param service Service name in lower-case, e.g. {@code "ai3d"} + * @param host API host, e.g. {@code "ai3d.tencentcloudapi.com"} + * @param payload Request body (JSON for POST) + * @return headers (Authorization, X-TC-Timestamp, Host) ready to attach to the HTTP request. + */ + public static SignedHeaders sign(String secretId, String secretKey, + String service, String host, String payload) { + long timestamp = System.currentTimeMillis() / 1000L; + return signAt(secretId, secretKey, service, host, payload, timestamp); + } + + /** Internal entry for tests — accepts a fixed timestamp. */ + static SignedHeaders signAt(String secretId, String secretKey, String service, + String host, String payload, long timestamp) { + String date = utcDate(timestamp); + + // ----- Step 1: canonical request ----- + String hashedPayload = sha256Hex(payload == null ? "" : payload); + String canonicalHeaders = "content-type:application/json; charset=utf-8\n" + + "host:" + host + "\n" + + "x-tc-action:"; // Action header value provided by caller via X-TC-Action; spec requires + // it to be lowercased here. We append it via the placeholder below. + // Note: TC3 does NOT include x-tc-action in the canonical headers — it's + // part of the request, not the signature. Recompute strictly: + canonicalHeaders = "content-type:application/json; charset=utf-8\n" + + "host:" + host + "\n"; + String signedHeaders = "content-type;host"; + + String canonicalRequest = + "POST\n" + + "/\n" + + "\n" + + canonicalHeaders + + "\n" + + signedHeaders + "\n" + + hashedPayload; + + // ----- Step 2: string to sign ----- + String credentialScope = date + "/" + service + "/tc3_request"; + String stringToSign = + "TC3-HMAC-SHA256\n" + + timestamp + "\n" + + credentialScope + "\n" + + sha256Hex(canonicalRequest); + + // ----- Step 3: signature ----- + byte[] secretDate = hmacSha256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); + byte[] secretService = hmacSha256(secretDate, service); + byte[] secretSigning = hmacSha256(secretService, "tc3_request"); + String signature = toHex(hmacSha256(secretSigning, stringToSign)); + + // ----- Step 4: Authorization header ----- + String authorization = "TC3-HMAC-SHA256 " + + "Credential=" + secretId + "/" + credentialScope + ", " + + "SignedHeaders=" + signedHeaders + ", " + + "Signature=" + signature; + + return new SignedHeaders(authorization, String.valueOf(timestamp), host); + } + + // ===== primitives ===== + + private static String utcDate(long epochSeconds) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd"); + fmt.setTimeZone(TimeZone.getTimeZone("UTC")); + return fmt.format(new Date(epochSeconds * 1000L)); + } + + private static String sha256Hex(String s) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return toHex(md.digest(s.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static byte[] hmacSha256(byte[] key, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("HmacSHA256 unavailable", e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + /** + * Build the canonical {@code X-TC-*} header set a Tencent Cloud v3 request + * needs in addition to {@code Authorization}. Caller must add {@code Host} + * and {@code Content-Type: application/json; charset=utf-8} themselves + * (those participate in the signature and are part of the wire request). + */ + public static Map commonHeaders(String action, String version, + String region, String timestamp) { + Map h = new LinkedHashMap<>(); + h.put("X-TC-Action", action); + h.put("X-TC-Version", version); + h.put("X-TC-Timestamp", timestamp); + if (region != null && !region.isBlank()) { + h.put("X-TC-Region", region); + } + return h; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java index 237e1126..181da4b4 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java @@ -94,6 +94,20 @@ public class MessageContentPart { return part; } + /** + * 3D model asset (.glb / .obj / .fbx). The frontend renders this with a + * <model-viewer> Web Component when contentType starts with + * {@code model/} (e.g. {@code model/gltf-binary} for glb). + */ + public static MessageContentPart model3d(String mediaId, String fileName) { + MessageContentPart part = new MessageContentPart(); + part.setType("model3d"); + part.setMediaId(mediaId); + part.setFileName(fileName); + part.setContentType("model/gltf-binary"); + return part; + } + public static MessageContentPart toolCall(String jsonPayload) { MessageContentPart part = new MessageContentPart(); part.setType("tool_call"); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V71__hunyuan_3d_provider.sql b/mateclaw-server/src/main/resources/db/migration/h2/V71__hunyuan_3d_provider.sql new file mode 100644 index 00000000..f6edb4b7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V71__hunyuan_3d_provider.sql @@ -0,0 +1,27 @@ +-- V71: Register Tencent Hunyuan 3D provider for ai3d service. +-- The api_key column carries "SecretId:SecretKey" (colon-joined) — same +-- two-part credential pattern as Kling. base_url defaults to the auto-routed +-- ai3d.tencentcloudapi.com host but can be pointed at a regional endpoint +-- (e.g. ai3d.ap-guangzhou.tencentcloudapi.com) when the operator wants to pin. +-- +-- chat_model is intentionally non-empty (a placeholder marker for the +-- generic ProviderInitProbe) — Hunyuan 3D is not actually a chat-completions +-- model, but the provider table currently requires the column. The probe +-- skips providers tagged is_local=TRUE / freeze_url=TRUE for chat liveness, +-- so the freeze_url=TRUE flag below also keeps the LLM failover chain from +-- ever attempting to dispatch chat traffic here. + +MERGE INTO mate_model_provider ( + provider_id, name, api_key_prefix, chat_model, api_key, base_url, + generate_kwargs, is_custom, is_local, support_model_discovery, + support_connection_check, freeze_url, require_api_key, auth_type, + create_time, update_time +) +KEY (provider_id) +VALUES ( + 'hunyuan-3d', '腾讯混元 3D', 'AKID', 'NotApplicable', '', + 'https://ai3d.tencentcloudapi.com', + '{"service":"ai3d","version":"2025-05-13","region":"ap-guangzhou"}', + FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, 'tc3_hmac_sha256', + NOW(), NOW() +); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V72__hunyuan_3d_model_config.sql b/mateclaw-server/src/main/resources/db/migration/h2/V72__hunyuan_3d_model_config.sql new file mode 100644 index 00000000..1aade616 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V72__hunyuan_3d_model_config.sql @@ -0,0 +1,54 @@ +-- V72: Register the three Tencent Hunyuan 3D model variants in +-- mate_model_config so the "Models & Credentials" provider card surfaces +-- them in the picker. Hunyuan 3D is not a chat-completions model; +-- model_type='model3d' (consistent with model_type='image' / 'video' used +-- for generative non-chat providers — see V32 bailian-team). +-- +-- Model -> backend Action mapping (see HunyuanModel3dProvider): +-- HY-3D-Express -> SubmitHunyuanTo3DRapidJob (Prompt/ImageUrl only, fastest) +-- HY-3D-3.0 -> SubmitHunyuanTo3DProJob (full feature set) +-- HY-3D-3.1 -> SubmitHunyuanTo3DProJob (latest Pro behavior on +-- X-TC-Version=2025-05-13; +-- differs from 3.0 internally) + +MERGE INTO mate_model_config ( + id, name, provider, model_name, description, + temperature, max_tokens, top_p, builtin, enabled, is_default, + model_type, create_time, update_time, deleted +) +KEY (id) +VALUES ( + 1000000500, 'HY-3D-3.1', 'hunyuan-3d', 'HY-3D-3.1', + '腾讯混元 3D 3.1 — 最高精度,支持 PBR / 多视角 / Geometry 白模等专业参数', + NULL, NULL, NULL, + TRUE, TRUE, TRUE, + 'model3d', NOW(), NOW(), 0 +); + +MERGE INTO mate_model_config ( + id, name, provider, model_name, description, + temperature, max_tokens, top_p, builtin, enabled, is_default, + model_type, create_time, update_time, deleted +) +KEY (id) +VALUES ( + 1000000501, 'HY-3D-3.0', 'hunyuan-3d', 'HY-3D-3.0', + '腾讯混元 3D 3.0 — 老一代 Pro 模型,与 3.1 共享 SubmitHunyuanTo3DProJob 调用', + NULL, NULL, NULL, + TRUE, TRUE, FALSE, + 'model3d', NOW(), NOW(), 0 +); + +MERGE INTO mate_model_config ( + id, name, provider, model_name, description, + temperature, max_tokens, top_p, builtin, enabled, is_default, + model_type, create_time, update_time, deleted +) +KEY (id) +VALUES ( + 1000000502, 'HY-3D-Express', 'hunyuan-3d', 'HY-3D-Express', + '腾讯混元 3D 极速版 — 走 SubmitHunyuanTo3DRapidJob 接口,速度最快但仅支持 Prompt / ImageUrl', + NULL, NULL, NULL, + TRUE, TRUE, FALSE, + 'model3d', NOW(), NOW(), 0 +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V71__hunyuan_3d_provider.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V71__hunyuan_3d_provider.sql new file mode 100644 index 00000000..90b5d959 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V71__hunyuan_3d_provider.sql @@ -0,0 +1,29 @@ +-- V71: Register Tencent Hunyuan 3D provider for ai3d service. +-- See h2/V71 for full rationale. + +INSERT INTO mate_model_provider ( + provider_id, name, api_key_prefix, chat_model, api_key, base_url, + generate_kwargs, is_custom, is_local, support_model_discovery, + support_connection_check, freeze_url, require_api_key, auth_type, + create_time, update_time +) VALUES ( + 'hunyuan-3d', '腾讯混元 3D', 'AKID', 'NotApplicable', '', + 'https://ai3d.tencentcloudapi.com', + '{"service":"ai3d","version":"2025-05-13","region":"ap-guangzhou"}', + 0, 0, 0, 0, 1, 1, 'tc3_hmac_sha256', + NOW(), NOW() +) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + api_key_prefix = VALUES(api_key_prefix), + chat_model = VALUES(chat_model), + base_url = VALUES(base_url), + generate_kwargs = VALUES(generate_kwargs), + is_custom = VALUES(is_custom), + is_local = VALUES(is_local), + support_model_discovery = VALUES(support_model_discovery), + support_connection_check = VALUES(support_connection_check), + freeze_url = VALUES(freeze_url), + require_api_key = VALUES(require_api_key), + auth_type = VALUES(auth_type), + update_time = NOW(); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V72__hunyuan_3d_model_config.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V72__hunyuan_3d_model_config.sql new file mode 100644 index 00000000..1f0af74d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V72__hunyuan_3d_model_config.sql @@ -0,0 +1,25 @@ +-- V72: Register Tencent Hunyuan 3D model variants. See h2/V72 for full rationale. + +INSERT INTO mate_model_config ( + id, name, provider, model_name, description, + temperature, max_tokens, top_p, builtin, enabled, is_default, + model_type, create_time, update_time, deleted +) VALUES + (1000000500, 'HY-3D-3.1', 'hunyuan-3d', 'HY-3D-3.1', + '腾讯混元 3D 3.1 — 最高精度,支持 PBR / 多视角 / Geometry 白模等专业参数', + NULL, NULL, NULL, 1, 1, 1, 'model3d', NOW(), NOW(), 0), + (1000000501, 'HY-3D-3.0', 'hunyuan-3d', 'HY-3D-3.0', + '腾讯混元 3D 3.0 — 老一代 Pro 模型,与 3.1 共享 SubmitHunyuanTo3DProJob 调用', + NULL, NULL, NULL, 1, 1, 0, 'model3d', NOW(), NOW(), 0), + (1000000502, 'HY-3D-Express', 'hunyuan-3d', 'HY-3D-Express', + '腾讯混元 3D 极速版 — 走 SubmitHunyuanTo3DRapidJob 接口,速度最快但仅支持 Prompt / ImageUrl', + NULL, NULL, NULL, 1, 1, 0, 'model3d', NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + model_name = VALUES(model_name), + description = VALUES(description), + builtin = VALUES(builtin), + enabled = VALUES(enabled), + is_default = VALUES(is_default), + model_type = VALUES(model_type), + update_time = NOW(); diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index c091c9b5..a9c51900 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -77,6 +77,7 @@ tool.shell.error.exception=\u6267\u884c\u5f02\u5e38: {0} tool.image_generate.desc=\u751f\u6210\u56fe\u7247\u3002\u6839\u636e\u6587\u5b57\u63cf\u8ff0\u521b\u4f5c\u56fe\u50cf\uff0c\u652f\u6301 DashScope\u3001OpenAI\u3001fal.ai\u3001\u667a\u8c31 CogView \u7b49 Provider\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u56fe\u7247\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002 tool.video_generate.desc=\u751f\u6210\u89c6\u9891\u3002\u652f\u6301\u6587\u751f\u89c6\u9891\u3001\u56fe\u751f\u89c6\u9891\u7b49\u6a21\u5f0f\uff0c\u652f\u6301 DashScope\u3001\u667a\u8c31 CogVideo\u3001Kling\u3001Runway\u3001MiniMax \u3001fal.ai \u7b49 Provider\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff08\u7ea6 1-5 \u5206\u949f\uff09\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u89c6\u9891\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002 tool.music_generate.desc=\u751f\u6210\u97f3\u4e50\u6216\u6b4c\u66f2\u3002\u652f\u6301\u6587\u5b57\u63cf\u8ff0\u751f\u6210\u97f3\u4e50\u3001\u6b4c\u8bcd\u8c31\u66f2\u3001\u7eaf\u97f3\u4e50\u7b49\u6a21\u5f0f\uff0c\u652f\u6301 Google Lyria \u548c MiniMax Music \u7b49 Provider\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff08\u7ea6 1-3 \u5206\u949f\uff09\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u97f3\u9891\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002 +tool.model3d_generate.desc=\u751f\u6210 3D \u6a21\u578b (.glb)\u3002\u652f\u6301\u6587\u751f 3D \u4e0e\u56fe\u751f 3D \u4e24\u79cd\u6a21\u5f0f\uff0c\u76ee\u524d Provider \u4e3a\u817e\u8baf\u6df7\u5143 3D Rapid\u3002\u4efb\u52a1\u5f02\u6b65\u6267\u884c\uff08\u7ea6 1-3 \u5206\u949f\uff09\uff0c\u5b8c\u6210\u540e\u524d\u7aef\u4f1a\u81ea\u52a8\u63a5\u6536 SSE \u4e8b\u4ef6 async_task_completed \u5e76\u628a\u6a21\u578b\u63a8\u5230\u5bf9\u8bdd\u4e2d\uff08\u5e26 model-viewer \u9884\u89c8\uff09\uff0c\u65e0\u9700\u624b\u52a8\u5237\u65b0\u3002 # --- Guard Rules --- guard.SHELL_RM_RF_ROOT.name=\u9012\u5f52\u5f3a\u5236\u5220\u9664\u6839\u76ee\u5f55 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 0cdd51d8..69fcb9d9 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -77,6 +77,7 @@ tool.shell.error.exception=Execution exception: {0} tool.image_generate.desc=Generate an image from a text description. Supports DashScope, OpenAI, fal.ai, Zhipu CogView, etc. Runs asynchronously; the client receives the image automatically via the SSE async_task_completed event without a manual refresh. tool.video_generate.desc=Generate a video. Supports text-to-video and image-to-video modes via DashScope, Zhipu CogVideo, Kling, Runway, MiniMax, fal.ai, etc. Runs asynchronously (1-5 minutes); the client receives the video automatically via the SSE async_task_completed event without a manual refresh. tool.music_generate.desc=Generate music or a song. Supports text-to-music, lyrics composition, and instrumental modes via Google Lyria and MiniMax Music. Runs asynchronously (1-3 minutes); the client receives the audio automatically via the SSE async_task_completed event without a manual refresh. +tool.model3d_generate.desc=Generate a 3D model (.glb). Supports text-to-3D and image-to-3D modes via Tencent Hunyuan 3D Rapid. Runs asynchronously (1-3 minutes); the client receives the model automatically via the SSE async_task_completed event with an inline model-viewer preview, no manual refresh required. # --- Guard Rules --- guard.SHELL_RM_RF_ROOT.name=Recursive force delete root directory diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 335560fc..17532416 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", + "@google/model-viewer": "^4.2.0", "axios": "^1.7.9", "dayjs": "^1.11.13", "dompurify": "^3.3.3", diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index 45df5c01..1c2dda62 100644 --- a/mateclaw-ui/pnpm-lock.yaml +++ b/mateclaw-ui/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@element-plus/icons-vue': specifier: ^2.3.1 version: 2.3.2(vue@3.5.31(typescript@5.7.3)) + '@google/model-viewer': + specifier: ^4.2.0 + version: 4.2.0(three@0.182.0) axios: specifier: ^1.7.9 version: 1.14.0 @@ -343,6 +346,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@google/model-viewer@4.2.0': + resolution: {integrity: sha512-RjpAI5cLs9CdvPcMRsOs8Bea/lNmGTTyaPyl16o9Fv6Qn8VSpgBMmXFr/11yb0hTrsojp2dOACEcY77R8hVUVA==} + engines: {node: '>=6.0.0'} + peerDependencies: + three: ^0.182.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -393,9 +402,20 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@mermaid-js/parser@1.1.0': resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@monogrid/gainmap-js@3.4.0': + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} @@ -1380,6 +1400,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1403,6 +1426,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -1451,6 +1477,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1525,6 +1554,15 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.2: + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} + + lit@3.3.2: + resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1698,6 +1736,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + promise-worker-transferable@1.0.4: + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -1777,6 +1818,9 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + three@0.182.0: + resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==} + tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} @@ -2116,6 +2160,12 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@google/model-viewer@4.2.0(three@0.182.0)': + dependencies: + '@monogrid/gainmap-js': 3.4.0(three@0.182.0) + lit: 3.3.2 + three: 0.182.0 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2166,10 +2216,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lit-labs/ssr-dom-shim@1.5.1': {} + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@mermaid-js/parser@1.1.0': dependencies: langium: 4.2.2 + '@monogrid/gainmap-js@3.4.0(three@0.182.0)': + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.182.0 + '@rolldown/pluginutils@1.0.0-rc.2': {} '@rollup/rollup-android-arm-eabi@4.60.1': @@ -2448,8 +2509,7 @@ snapshots: '@types/lodash@4.17.24': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/web-bluetooth@0.0.20': {} @@ -3199,6 +3259,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3216,6 +3278,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-promise@2.2.2: {} + is-what@5.5.0: {} isexe@2.0.0: {} @@ -3260,6 +3324,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -3309,6 +3377,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.2 + + lit-html@3.3.2: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.2: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3478,6 +3562,11 @@ snapshots: prelude-ls@1.2.1: {} + promise-worker-transferable@1.0.4: + dependencies: + is-promise: 2.2.2 + lie: 3.3.0 + proxy-from-env@2.1.0: {} punycode@2.3.1: {} @@ -3560,6 +3649,8 @@ snapshots: tapable@2.3.2: {} + three@0.182.0: {} + tinyexec@1.1.1: {} tinyglobby@0.2.15: diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 7f052da9..63b391fb 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -208,6 +208,25 @@ /> {{ attachment.name }} + +
+ + {{ attachment.name }} +