feat(model3d): Tencent Hunyuan 3D provider — Pro/Rapid action routing + <model-viewer> preview

This commit is contained in:
matevip 2026-05-01 22:39:13 +08:00
parent e3ab06d57c
commit 28c3b0e62f
34 changed files with 1745 additions and 8 deletions

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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
}

View File

@ -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.
* <p>
* 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";
}
}

View File

@ -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 为腾讯混元 3DHY-3D-3.1 / HY-3D-3.0 走 Pro 接口HY-3D-Express 走 Rapid 极速接口)。任务异步执行(约 1-3 分钟),工具立即返回任务 ID前端会在生成完成时自动接收 SSE 事件并把 3D 模型推到对话中(带 <model-viewer> 预览),无需用户手动刷新。")
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 = "是否生成纹理,默认 truefalse 走 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();
}
}
}

View File

@ -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<Model3dCapability> 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);
}

View File

@ -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<String> 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<String, Object> extraParams;
}

View File

@ -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();
}
}

View File

@ -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}.
* <p>
* 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.
* <p>
* 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<String> 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
// <model-viewer> 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<String, Object> 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()));
}
}

View File

@ -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<Model3dCapability> modes = Set.of(Model3dCapability.TEXT_TO_3D);
/** Output formats the provider can return: glb / obj / fbx / usdz. */
@Builder.Default
private List<String> 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<String> 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);
}
}

View File

@ -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<Model3dGenerationProvider> sortedProviders;
private final Map<String, Model3dGenerationProvider> providerMap;
public Model3dProviderRegistry(List<Model3dGenerationProvider> 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<Model3dGenerationProvider> 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);
}
}

View File

@ -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();
}
}

View File

@ -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).
*
* <h3>Credentials</h3>
* 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}).
*
* <h3>API surface</h3>
* <ul>
* <li>{@code SubmitHunyuanTo3DRapidJob} accepts {@code Prompt} OR {@code ImageUrl} (mutually exclusive)</li>
* <li>{@code QueryHunyuanTo3DRapidJob} polls Status {WAIT, RUN, DONE, FAIL}</li>
* <li>Region: {@code ap-guangzhou} (the only documented region for ai3d)</li>
* <li>Concurrency: 1 task by default {@link vip.mate.tool.ConcurrencyUnsafe} on the tool ensures the agent doesn't fire two in parallel</li>
* </ul>
*
* <h3>Result delivery contract</h3>
* On {@code DONE}, {@link #checkStatus} returns
* {@code TaskPollResult.succeeded(null, null, <json>)} where {@code <json>} is
* {@code {"modelUrl": "<glb-url>", "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<Model3dCapability> 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<String> 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.
* <p>
* 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 <model-viewer>} can't render directly. The GLB entry
* is a single self-contained binary that <em>is</em> 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<String, String> 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<String, String> e : common.entrySet()) {
req.header(e.getKey(), e.getValue());
}
try (HttpResponse response = req.execute()) {
return objectMapper.readTree(response.body());
}
}
}

View File

@ -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.
* <p>
* Spec: <a href="https://cloud.tencent.com/document/api/1804/120833">签名方法 v3</a>.
* 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.
*
* <p>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.</p>
*/
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<String, String> commonHeaders(String action, String version,
String region, String timestamp) {
Map<String, String> 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;
}
}

View File

@ -94,6 +94,20 @@ public class MessageContentPart {
return part;
}
/**
* 3D model asset (.glb / .obj / .fbx). The frontend renders this with a
* &lt;model-viewer&gt; 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");

View File

@ -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()
);

View File

@ -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
);

View File

@ -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();

View File

@ -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();

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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:

View File

@ -208,6 +208,25 @@
/>
<span class="message-attachment-audio__name">{{ attachment.name }}</span>
</div>
<!-- 3D model preview via @google/model-viewer Web Component
(registered globally in src/main.ts; renders &lt;model-viewer&gt;
as a custom HTML element). -->
<div
v-for="attachment in model3dAttachments"
:key="'model3d-' + attachment.storedName"
class="message-attachment-model3d"
>
<model-viewer
:src="getDisplayUrl(attachment)"
camera-controls
auto-rotate
shadow-intensity="1"
exposure="1"
alt="Generated 3D model"
class="message-attachment-model3d__viewer"
/>
<span class="message-attachment-model3d__name">{{ attachment.name }}</span>
</div>
<button
v-for="attachment in fileAttachments"
:key="attachment.storedName"
@ -307,7 +326,7 @@ import type { ChatErrorInfo } from '@/types/chatError'
const { renderMarkdown } = useMarkdownRenderer()
const { t } = useI18n()
const { getToolLabel } = useToolLabel()
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, loadAllModels, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
interface Props {
message: Message
@ -575,12 +594,15 @@ const mediaPartAttachments = computed<ChatAttachment[]>(() => {
const seen = new Set<string>()
for (const p of parts) {
if (!p || !p.fileUrl) continue
if (p.type !== 'image' && p.type !== 'audio' && p.type !== 'video') continue
if (p.type !== 'image' && p.type !== 'audio' && p.type !== 'video' && p.type !== 'model3d') continue
if (existingUrls.has(p.fileUrl) || seen.has(p.fileUrl)) continue
seen.add(p.fileUrl)
const fileName = p.fileName || p.fileUrl.split('/').pop() || `${p.type}-${out.length}`
const ct = p.contentType
|| (p.type === 'image' ? 'image/png' : p.type === 'audio' ? 'audio/mpeg' : 'video/mp4')
|| (p.type === 'image' ? 'image/png'
: p.type === 'audio' ? 'audio/mpeg'
: p.type === 'video' ? 'video/mp4'
: 'model/gltf-binary')
out.push({
name: fileName,
size: 0,
@ -600,10 +622,12 @@ const attachments = computed(() => [
const imageAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('image/')))
const videoAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('video/')))
const audioAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('audio/')))
const model3dAttachments = computed(() => attachments.value.filter(a => a.contentType?.startsWith('model/')))
const fileAttachments = computed(() => attachments.value.filter(a =>
!a.contentType?.startsWith('image/')
&& !a.contentType?.startsWith('video/')
&& !a.contentType?.startsWith('audio/')
&& !a.contentType?.startsWith('model/')
))
// // blob URLwatch +
@ -616,6 +640,11 @@ watch(videoAttachments, (atts) => {
watch(audioAttachments, (atts) => {
if (atts.length > 0) loadAllAudios(atts)
}, { immediate: true })
// 3D models also need the auth-blob loader <model-viewer src> doesn't carry
// the Authorization header any more than <img>/<audio> do.
watch(model3dAttachments, (atts) => {
if (atts.length > 0) loadAllModels(atts)
}, { immediate: true })
// --- ---
const formattedTime = computed(() => {
@ -1452,6 +1481,33 @@ watch(isGenerating, (generating) => {
white-space: nowrap;
}
.message-attachment-model3d {
border-radius: 12px;
overflow: hidden;
background: var(--bg-soft, #f5f5f5);
}
.message-attachment-model3d__viewer {
width: 100%;
max-width: 480px;
height: 360px;
display: block;
border-radius: 12px;
/* model-viewer renders nothing until the .glb finishes loading;
keep the box sized so layout doesn't jump. */
background: linear-gradient(135deg, #fafafa, #ececec);
}
.message-attachment-model3d__name {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.76;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment {
display: flex;
align-items: center;

View File

@ -275,7 +275,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
/任务\s*ID[=:"\s]+([a-f0-9]{16})/i,
/task[_\s]*id[=:"\s]+([a-f0-9]{16})/i,
]
const ASYNC_TOOL_NAMES = new Set(['music_generate', 'video_generate', 'image_generate'])
const ASYNC_TOOL_NAMES = new Set(['music_generate', 'video_generate', 'image_generate', 'model3d_generate'])
let reconnectingForAsyncTasks = false
function extractTaskId(result: unknown): string | null {
@ -1190,6 +1190,17 @@ export function useChat(options: UseChatOptions): UseChatReturn {
fileName: `music_${data.taskId}.${fmt}`,
contentType: fmt === 'wav' ? 'audio/wav' : 'audio/mpeg',
} as MessageContentPart
} else if (data.modelUrl) {
const fmt = ((data.format || 'glb') as string).toLowerCase()
mediaPart = {
type: 'model3d',
fileUrl: data.modelUrl,
fileName: `model_${data.taskId}.${fmt}`,
contentType: fmt === 'obj' ? 'model/obj'
: fmt === 'fbx' ? 'model/fbx'
: fmt === 'usdz' ? 'model/vnd.usdz+zip'
: 'model/gltf-binary',
} as MessageContentPart
}
if (!mediaPart) return

View File

@ -84,6 +84,19 @@ export function useAuthenticatedAttachment() {
}
}
/**
* 3D blob URL&lt;model-viewer src&gt; Authorization
*/
async function loadAllModels(attachments: ChatAttachment[]) {
const modelAtts = attachments.filter(a => a.contentType?.startsWith('model/'))
for (const att of modelAtts) {
const key = att.storedName || att.url
if (!att.previewUrl && att.url && !blobUrls.value[key]) {
await loadBlobUrl(att.url, key)
}
}
}
/**
* fetch blob <a download>
*/
@ -154,6 +167,7 @@ export function useAuthenticatedAttachment() {
loadAllImages,
loadAllVideos,
loadAllAudios,
loadAllModels,
downloadFile,
openImage,
getDisplayUrl,

View File

@ -317,6 +317,7 @@ export default {
stt: 'Speech Recognition',
music: 'Music Generation',
video: 'Video Generation',
model3d: '3D Generation',
about: 'About',
advanced: 'Advanced',
},
@ -537,6 +538,9 @@ export default {
musicEnabled: 'Enable Music Generation',
musicProvider: 'Preferred Music Provider',
musicFallbackEnabled: 'Provider Fallback',
model3dEnabled: 'Enable 3D Model Generation',
model3dProvider: 'Preferred 3D Provider',
model3dFallbackEnabled: 'Provider Fallback',
ttsEnabled: 'Enable Text-to-Speech',
ttsProvider: 'Preferred TTS Provider',
ttsFallbackEnabled: 'Provider Fallback',
@ -583,6 +587,10 @@ export default {
musicFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
googleLyriaInfo: 'Reuses Google API Key from Model Management. Lyria 3 model, supports lyrics and instrumental.',
minimaxMusicInfo: 'Reuses MiniMax API Key from video settings. music-2.5+ model, supports lyrics and instrumental.',
model3dEnabled: 'Enable to let Agent use model3d_generate (.glb output). Configure the hunyuan-3d provider with SecretId:SecretKey first under Models & Credentials.',
model3dProvider: 'Select preferred 3D provider. Auto mode picks the first available one.',
model3dFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
hunyuan3dInfo: 'Tencent Hunyuan 3D (ai3d.tencentcloudapi.com). HY-3D-3.1 / HY-3D-3.0 use the Pro action; HY-3D-Express uses the Rapid action. Configure as SecretId:SecretKey under Models & Credentials.',
ttsEnabled: 'Enable to use TTS via Read Aloud button or auto mode. Edge TTS is free, no API key needed.',
ttsProvider: 'Select preferred TTS provider. Auto mode prioritizes free Edge TTS.',
ttsFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
@ -623,6 +631,10 @@ export default {
musicDesc: 'Configure AI music generation with Google Lyria and MiniMax Music',
musicProviderOptions: { auto: 'Auto Select' },
musicProviderTags: { reuseLlmKey: 'Reuses LLM API Key', sharedWithVideo: 'Shared with Video' },
model3dTitle: '3D Model Generation',
model3dDesc: 'Configure AI 3D model generation with Tencent Hunyuan 3D (HY-3D-3.1 / HY-3D-3.0 / HY-3D-Express)',
model3dProviderOptions: { auto: 'Auto Select', hunyuan: 'Tencent Hunyuan 3D' },
model3dProviderTags: { requiresKey: 'Requires SecretId:SecretKey' },
ttsTitle: 'Text-to-Speech',
ttsDesc: 'Configure TTS with Edge TTS (free), OpenAI TTS, and DashScope CosyVoice',
ttsProviderOptions: {

View File

@ -307,6 +307,7 @@ export default {
stt: '语音识别',
music: '音乐生成',
video: '视频生成',
model3d: '3D 生成',
about: '关于',
advanced: '高级',
},
@ -529,6 +530,10 @@ export default {
musicEnabled: '启用音乐生成',
musicProvider: '首选音乐提供商',
musicFallbackEnabled: '提供商回退',
// 3D 模型生成
model3dEnabled: '启用 3D 模型生成',
model3dProvider: '首选 3D 提供商',
model3dFallbackEnabled: '提供商回退',
// TTS 语音合成
ttsEnabled: '启用语音合成',
ttsProvider: '首选 TTS 提供商',
@ -580,6 +585,11 @@ export default {
musicFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
googleLyriaInfo: '复用模型管理中的 Google API Key。Lyria 3 模型,支持歌词谱曲和纯音乐生成。',
minimaxMusicInfo: '复用视频生成中的 MiniMax API Key。music-2.5+ 模型,支持歌词和纯音乐。',
// 3D 模型生成
model3dEnabled: '开启后 Agent 可通过 model3d_generate 工具生成 3D 模型 (.glb)。需先在「模型与凭据」配置 hunyuan-3d 的 SecretId:SecretKey。',
model3dProvider: '选择首选 3D 提供商auto 模式按优先级自动选用。',
model3dFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
hunyuan3dInfo: '腾讯混元 3Dai3d.tencentcloudapi.com。HY-3D-3.1 / HY-3D-3.0 走 Pro 接口HY-3D-Express 走 Rapid 极速接口。在「模型与凭据」中以 SecretId:SecretKey 格式保存。',
// TTS 语音合成
ttsEnabled: '开启后可通过消息朗读按钮或自动模式使用语音合成。Edge TTS 免费无需 Key。',
ttsProvider: '选择首选 TTS 提供商auto 模式优先使用免费的 Edge TTS。',
@ -623,6 +633,10 @@ export default {
musicDesc: '配置 AI 音乐生成,支持 Google Lyria 和 MiniMax Music',
musicProviderOptions: { auto: '自动选择' },
musicProviderTags: { reuseLlmKey: '复用 LLM API Key', sharedWithVideo: '与视频共用' },
model3dTitle: '3D 模型生成',
model3dDesc: '配置 AI 3D 模型生成,支持腾讯混元 3DHY-3D-3.1 / HY-3D-3.0 / HY-3D-Express',
model3dProviderOptions: { auto: '自动选择', hunyuan: '腾讯混元 3D' },
model3dProviderTags: { requiresKey: '需配置 SecretId:SecretKey' },
ttsTitle: '语音合成',
ttsDesc: '配置 TTS 语音合成,支持 Edge TTS免费、OpenAI TTS、DashScope CosyVoice',
ttsProviderOptions: {

View File

@ -9,6 +9,11 @@ import router from './router'
import './assets/main.css'
import { i18n, initializeLocale } from './i18n'
// Side-effect import: registers the <model-viewer> Web Component globally so
// generated 3D assets (.glb) can be previewed inline in chat bubbles. Vue's
// compiler is told to treat the tag as a custom element via vite.config.ts.
import '@google/model-viewer'
async function bootstrap() {
await initializeLocale()

View File

@ -124,6 +124,12 @@ const router = createRouter({
component: () => import('@/views/Settings/Video/index.vue'),
meta: { title: 'Settings - Video' },
},
{
path: 'model3d',
name: 'SettingsModel3D',
component: () => import('@/views/Settings/Model3D/index.vue'),
meta: { title: 'Settings - 3D Model' },
},
// Workspace management
{
path: 'workspaces',

View File

@ -188,7 +188,7 @@ export interface MessageMetadata {
}
export interface MessageContentPart {
type: 'text' | 'thinking' | 'image' | 'file' | 'audio' | 'video' | 'tool_call' | 'parse_error'
type: 'text' | 'thinking' | 'image' | 'file' | 'audio' | 'video' | 'model3d' | 'tool_call' | 'parse_error'
text?: string
fileUrl?: string
fileName?: string

View File

@ -122,6 +122,12 @@ const sections = computed(() => [
label: t('settings.sections.video'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
},
{
id: 'model3d',
path: '/settings/model3d',
label: t('settings.sections.model3d'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 L21 7 L21 17 L12 22 L3 17 L3 7 Z"/><path d="M3 7 L12 12 L21 7"/><path d="M12 12 L12 22"/></svg>',
},
// Divider: Workspace
{ id: 'divider-workspace', path: '', label: t('settings.sections.workspace', 'Workspace'), icon: '', isDivider: true },
{

View File

@ -0,0 +1,139 @@
<template>
<div class="settings-section">
<div class="section-header">
<h2 class="section-title">{{ t('settings.model3dTitle') }}</h2>
<p class="section-desc">{{ t('settings.model3dDesc') }}</p>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.model3dEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.model3dEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.model3dEnabled" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.model3dProvider') }}</div>
<div class="setting-hint">{{ t('settings.hints.model3dProvider') }}</div>
</div>
<div class="setting-control">
<select v-model="settings.model3dProvider" class="form-input" :disabled="!settings.model3dEnabled">
<option value="auto">{{ t('settings.model3dProviderOptions.auto') }}</option>
<option value="hunyuan-3d">{{ t('settings.model3dProviderOptions.hunyuan') }}</option>
</select>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.model3dFallbackEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.model3dFallbackEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.model3dFallbackEnabled" type="checkbox" :disabled="!settings.model3dEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<template v-if="settings.model3dEnabled">
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">{{ t('settings.model3dProviderOptions.hunyuan') }}</span>
<span class="provider-tag">{{ t('settings.model3dProviderTags.requiresKey') }}</span>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-hint">{{ t('settings.hints.hunyuan3dInfo') }}</div>
</div>
</div>
</div>
</div>
</template>
<div class="save-bar">
<button class="btn-secondary" @click="loadSettings">{{ t('common.reset') }}</button>
<button class="btn-primary" @click="onSaveSettings">{{ t('settings.actions.saveSystem') }}</button>
</div>
<div v-if="savedTip" class="save-tip">{{ savedTip }}</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
const { t } = useI18n()
const savedTip = ref('')
const settings = reactive({
model3dEnabled: false,
model3dProvider: 'auto',
model3dFallbackEnabled: true,
})
onMounted(() => loadSettings())
async function loadSettings() {
const res: any = await settingsApi.get()
const d = res.data || {}
settings.model3dEnabled = d.model3dEnabled ?? false
settings.model3dProvider = d.model3dProvider ?? 'auto'
settings.model3dFallbackEnabled = d.model3dFallbackEnabled ?? true
}
async function onSaveSettings() {
await settingsApi.update({
model3dEnabled: settings.model3dEnabled,
model3dProvider: settings.model3dProvider,
model3dFallbackEnabled: settings.model3dFallbackEnabled,
})
await loadSettings()
savedTip.value = t('settings.messages.saveSuccess')
setTimeout(() => { savedTip.value = '' }, 2500)
}
</script>
<style scoped>
.settings-section { width: 100%; }
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124,63,30,0.04); width: 100%; }
.setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
.setting-item:last-child { border-bottom: none; }
.setting-info { flex: 1; }
.setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
.setting-hint { font-size: 13px; color: var(--mc-text-secondary); }
.setting-control { width: 220px; display: flex; align-items: center; justify-content: flex-end; }
.form-input { width: 100%; border: 1px solid var(--mc-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.form-input:disabled { opacity: 0.5; cursor: not-allowed; }
.toggle-switch { position: relative; display: inline-flex; width: 44px; height: 24px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; cursor: pointer; background: var(--mc-border); border-radius: 999px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); }
.toggle-switch input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; }
.provider-section { margin-top: 24px; }
.provider-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.provider-name { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); }
.provider-tag { font-size: 12px; padding: 2px 8px; border-radius: 6px; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.save-bar { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn-primary, .btn-secondary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; }
.btn-primary { background: var(--mc-primary); color: white; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); }
.save-tip { position: fixed; right: 24px; bottom: 24px; background: var(--mc-text-primary); color: var(--mc-text-inverse); padding: 10px 14px; border-radius: 10px; box-shadow: 0 10px 30px rgba(124,63,30,0.22); }
</style>

View File

@ -5,7 +5,17 @@ import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
vue({
template: {
compilerOptions: {
// Treat <model-viewer> as a custom Web Component (registered globally
// via @google/model-viewer in main.ts) so Vue doesn't try to resolve
// it as a Vue component and emit a "Failed to resolve component"
// warning at runtime.
isCustomElement: (tag) => tag === 'model-viewer',
},
},
}),
tailwindcss(),
],
resolve: {