mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(model3d): Tencent Hunyuan 3D provider — Pro/Rapid action routing + <model-viewer> preview
This commit is contained in:
parent
e3ab06d57c
commit
28c3b0e62f
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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 模型推到对话中(带 <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 = "是否生成纹理,默认 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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()
|
||||
);
|
||||
@ -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
|
||||
);
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
95
mateclaw-ui/pnpm-lock.yaml
generated
95
mateclaw-ui/pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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 <model-viewer>
|
||||
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 URL(watch 覆盖首次 + 后续变化)
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -84,6 +84,19 @@ export function useAuthenticatedAttachment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载所有 3D 模型附件的 blob URL(<model-viewer src> 不带 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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: '腾讯混元 3D(ai3d.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 模型生成,支持腾讯混元 3D(HY-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: {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 },
|
||||
{
|
||||
|
||||
139
mateclaw-ui/src/views/Settings/Model3D/index.vue
Normal file
139
mateclaw-ui/src/views/Settings/Model3D/index.vue
Normal 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>
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user