Files
2026-07-13 12:49:17 +08:00

223 lines
8.7 KiB
C#

using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Tripo3D model provider. Submits text→3D / image→3D tasks to the OpenAPI task endpoint and
/// polls for completion. The bearer key is supplied per call and never logged; every error
/// message is run through <see cref="SecretRedactor"/> before it is surfaced.
/// </summary>
public sealed class TripoAdapter : IModelProviderAdapter
{
private const string TaskEndpoint = "https://api.tripo3d.ai/v2/openapi/task";
// Current recommended Tripo model (v3.1). Premium alternative: P1-20260311.
private const string ModelVersion = "v3.1-20260211";
public string Id => "tripo";
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
// Tripo rejects base64 data URIs and needs a multipart upload→token flow for local files,
// which isn't wired yet — fail clearly rather than silently falling back to text mode.
bool imageMode = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase);
if (imageMode && string.IsNullOrEmpty(req.ImageUrl))
throw new Exception("Tripo image input requires a hosted 'image_url'; local 'image_path' upload is not yet supported for Tripo (use Meshy for local-image→3D, or host the image).");
JObject body;
bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl);
if (image)
{
body = new JObject
{
["type"] = "image_to_model",
["file"] = new JObject
{
["type"] = "url",
["url"] = req.ImageUrl
}
};
}
else
{
body = new JObject
{
["type"] = "text_to_model",
["prompt"] = req.Prompt ?? string.Empty,
["model_version"] = ModelVersion
};
}
var spec = new HttpRequestSpec
{
Method = "POST",
Url = TaskEndpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "submit");
string taskId = json["data"]?["task_id"]?.ToString();
if (string.IsNullOrEmpty(taskId))
{
throw new Exception(SecretRedactor.Scrub(
"Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
}
return taskId;
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec
{
Method = "GET",
Url = TaskEndpoint + "/" + providerJobId
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "poll");
JObject data = json["data"] as JObject ?? new JObject();
var result = new ProviderPollResult { State = MapState(data["status"]?.ToString()) };
JToken progressTok = data["progress"];
if (progressTok != null && progressTok.Type != JTokenType.Null)
{
result.Progress = Mathf.Clamp01(progressTok.Value<float>() / 100f);
}
if (result.State == ProviderPollState.Succeeded)
{
result.Progress = 1f;
result.DownloadUrl = ExtractDownloadUrl(data);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "Tripo reported success but no model URL was present in the response.";
}
}
else if (result.State == ProviderPollState.Failed)
{
string err = data["error"]?.ToString()
?? data["message"]?.ToString()
?? json["message"]?.ToString()
?? "Tripo task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
}
private static ProviderPollState MapState(string status)
{
switch ((status ?? string.Empty).ToLowerInvariant())
{
case "success":
case "succeeded":
return ProviderPollState.Succeeded;
case "failed":
case "error":
case "cancelled":
case "canceled":
case "banned":
case "expired":
return ProviderPollState.Failed;
case "running":
case "processing":
return ProviderPollState.Running;
default:
return ProviderPollState.Queued;
}
}
/// <summary>
/// Resolve the model download URL, defensive about Tripo's nested output shapes. Prefer a
/// textured / PBR model; fall back to the base model. Accepts both the newer flat form
/// (<c>output.pbr_model</c> = url string) and the older nested form
/// (<c>result.pbr_model.url</c> = object with a url field).
/// </summary>
private static string ExtractDownloadUrl(JObject data)
{
JObject output = data["output"] as JObject;
JObject resultObj = data["result"] as JObject;
return UrlOf(output?["pbr_model"])
?? UrlOf(resultObj?["pbr_model"])
?? UrlOf(output?["model"])
?? UrlOf(resultObj?["model"])
?? UrlOf(output?["base_model"])
?? UrlOf(resultObj?["base_model"]);
}
/// <summary>A field may be a plain URL string or an object carrying a "url" property.</summary>
private static string UrlOf(JToken token)
{
if (token == null || token.Type == JTokenType.Null) return null;
if (token.Type == JTokenType.String)
{
string s = token.ToString();
return string.IsNullOrEmpty(s) ? null : s;
}
if (token is JObject obj)
{
string u = obj["url"]?.ToString();
return string.IsNullOrEmpty(u) ? null : u;
}
return null;
}
/// <summary>
/// Validate transport success + Tripo's body-level <c>code</c> (0 == ok), parse the JSON,
/// and throw a redacted exception otherwise.
/// </summary>
private static JObject ParseAndValidate(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
JObject json = null;
if (!string.IsNullOrEmpty(text))
{
try { json = JObject.Parse(text); }
catch { /* non-JSON body; handled below */ }
}
bool httpOk = res?.Ok == true;
int code = 0;
JToken codeTok = json?["code"];
if (codeTok != null && codeTok.Type != JTokenType.Null)
{
try { code = codeTok.Value<int>(); } catch { code = -1; }
}
if (!httpOk || code != 0)
{
string detail = json?["message"]?.ToString()
?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub(
$"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}