aimakeall-mcp 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/cloud-tools.mjs +111 -198
- package/lib/config.mjs +1 -1
- package/lib/media-store.mjs +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/lib/cloud-tools.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
fileToDataUrl,
|
|
15
15
|
fileToImagePayload,
|
|
16
16
|
guessMimeType,
|
|
17
|
+
saveExportBuffer,
|
|
17
18
|
saveMediaBuffer,
|
|
18
19
|
savePayloadHandle,
|
|
19
20
|
SERVER_JSON_BODY_LIMIT_BYTES,
|
|
@@ -46,6 +47,26 @@ function decodeHeaderValue(value) {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// 상품 이미지 URL 클라이언트 가드 — 서버가 1차 필터하지만 이 코드는 사용자 PC 에서
|
|
51
|
+
// 돌므로, 악성 페이지가 심은 내부망 URL(127.0.0.1:9876 컴패니언 등) fetch 를 이중 차단.
|
|
52
|
+
function isSafePublicImageUrl(rawUrl) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = new URL(String(rawUrl || ""));
|
|
55
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
56
|
+
const host = parsed.hostname.toLowerCase();
|
|
57
|
+
if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return false;
|
|
58
|
+
if (host.includes(":") || host.startsWith("[")) return false; // IPv6 리터럴은 스킵 (이미지 CDN 에 불필요)
|
|
59
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
60
|
+
const [a, b] = host.split(".").map(Number);
|
|
61
|
+
if (a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
62
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224) return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
// 실측 mp3 길이(초) — 파싱 실패 시에만 CBR-128 근사로 폴백.
|
|
50
71
|
function audioDurationSecFromFile(filePath) {
|
|
51
72
|
try {
|
|
@@ -165,6 +186,83 @@ export function registerCloudTools(server, config, api) {
|
|
|
165
186
|
}),
|
|
166
187
|
);
|
|
167
188
|
|
|
189
|
+
server.tool(
|
|
190
|
+
"research_product",
|
|
191
|
+
"상품 링크로 리뷰 기반 시장조사를 합니다: 구매자 불만 TOP3(근거 리뷰 인용)·첫 3초 후킹 패턴·경쟁 광고 공백·핵심 USP·금지 표현. 결과의 planBrief는 plan_commerce_video의 researchBrief로, productImagePaths는 productImagePaths로 그대로 이어가세요. 사이트가 서버 접근을 차단하면(fetchBlocked 안내) 에이전트가 직접 상품 페이지·리뷰를 읽어 pageText/reviewsText로 다시 호출하세요.",
|
|
192
|
+
{
|
|
193
|
+
productUrl: z.string().optional().describe("상품 페이지 URL (아마존·쇼핑몰 등)"),
|
|
194
|
+
productName: z.string().optional().describe("상품명 (URL 없이 텍스트만 넘길 때)"),
|
|
195
|
+
pageText: z.string().optional().describe("에이전트가 직접 읽은 상품 페이지 텍스트 (봇 차단 폴백)"),
|
|
196
|
+
reviewsText: z.string().optional().describe("에이전트가 수집한 구매 후기 텍스트 (봇 차단 폴백)"),
|
|
197
|
+
downloadImages: z.boolean().optional().describe("기본 true — 상품 이미지를 로컬 저장해 plan_commerce_video용 경로 반환"),
|
|
198
|
+
},
|
|
199
|
+
wrapCloudHandler(config, async ({ productUrl = "", productName = "", pageText = "", reviewsText = "", downloadImages = true }) => {
|
|
200
|
+
if (!productUrl.trim() && !pageText.trim() && !reviewsText.trim()) {
|
|
201
|
+
return textResult("productUrl 또는 pageText/reviewsText 중 하나는 필요합니다.", { isError: true });
|
|
202
|
+
}
|
|
203
|
+
const payload = await api.request("/api/tracker/commerce/product-research", {
|
|
204
|
+
body: { pageText, productName, reviewsText, url: productUrl },
|
|
205
|
+
method: "POST",
|
|
206
|
+
timeoutMs: 120_000,
|
|
207
|
+
});
|
|
208
|
+
if (!payload?.ok) {
|
|
209
|
+
return textResult(
|
|
210
|
+
`리서치 자료를 얻지 못했습니다: ${payload?.error || "자료 없음"}\n${payload?.guidance || ""}`,
|
|
211
|
+
{ isError: true },
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const productImagePaths = [];
|
|
215
|
+
if (downloadImages) {
|
|
216
|
+
const EXT_BY_SUBTYPE = { jpeg: "jpg", jpg: "jpg", png: "png", webp: "webp" };
|
|
217
|
+
for (const imageUrl of (Array.isArray(payload?.product?.imageUrls) ? payload.product.imageUrls : []).slice(0, 5)) {
|
|
218
|
+
if (!isSafePublicImageUrl(imageUrl)) continue;
|
|
219
|
+
try {
|
|
220
|
+
// redirect 미추적 — CDN 이 내부 주소로 리다이렉트시키는 우회 차단.
|
|
221
|
+
const res = await fetch(imageUrl, { redirect: "manual", signal: AbortSignal.timeout(30_000) });
|
|
222
|
+
if (!res.ok) continue;
|
|
223
|
+
const contentType = String(res.headers.get("content-type") || "").toLowerCase();
|
|
224
|
+
const subtype = contentType.startsWith("image/") ? contentType.slice(6).split(";")[0].trim() : "";
|
|
225
|
+
const ext = EXT_BY_SUBTYPE[subtype];
|
|
226
|
+
if (!ext) continue; // 이미지가 아니거나(HTML/SVG 등) 미지원 포맷
|
|
227
|
+
// 스트리밍 캡 — 전체 버퍼링 전에 8MB 에서 중단.
|
|
228
|
+
const parts = [];
|
|
229
|
+
let received = 0;
|
|
230
|
+
let tooLarge = false;
|
|
231
|
+
for await (const chunk of res.body) {
|
|
232
|
+
const piece = Buffer.from(chunk);
|
|
233
|
+
received += piece.length;
|
|
234
|
+
if (received > 8 * 1024 * 1024) {
|
|
235
|
+
tooLarge = true;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
parts.push(piece);
|
|
239
|
+
}
|
|
240
|
+
if (tooLarge || !received) continue;
|
|
241
|
+
const saved = saveMediaBuffer(config.stateDir, `product-${productImagePaths.length + 1}.${ext}`, Buffer.concat(parts));
|
|
242
|
+
productImagePaths.push(saved.filePath);
|
|
243
|
+
} catch {
|
|
244
|
+
// 이미지 한 장 실패는 무시
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return jsonResult({
|
|
249
|
+
fetchBlocked: payload?.fetchBlocked || false,
|
|
250
|
+
next: productImagePaths.length
|
|
251
|
+
? "plan_commerce_video에 productImagePaths와 researchBrief(planBrief)를 그대로 넘기세요."
|
|
252
|
+
: "상품 이미지를 확보하지 못했습니다 — 에이전트가 상품 사진을 로컬에 저장해 그 경로로 plan_commerce_video를 호출하세요 (researchBrief는 planBrief 사용).",
|
|
253
|
+
planBrief: payload?.planBrief,
|
|
254
|
+
product: {
|
|
255
|
+
imageCount: (Array.isArray(payload?.product?.imageUrls) ? payload.product.imageUrls : []).length,
|
|
256
|
+
name: payload?.product?.name,
|
|
257
|
+
price: payload?.product?.price,
|
|
258
|
+
reviewCount: payload?.product?.reviewCount,
|
|
259
|
+
},
|
|
260
|
+
productImagePaths,
|
|
261
|
+
research: payload?.research,
|
|
262
|
+
});
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
|
|
168
266
|
server.tool(
|
|
169
267
|
"plan_commerce_video",
|
|
170
268
|
"제품 홍보 영상 기획을 생성합니다. 제품 사진 파일 경로가 최소 1장 필요합니다 (이 PC의 로컬 경로).",
|
|
@@ -177,8 +275,9 @@ export function registerCloudTools(server, config, api) {
|
|
|
177
275
|
tone: z.string().optional().describe("기본 '자동 추천'"),
|
|
178
276
|
categoryId: z.string().optional().describe("기본 ecommerce"),
|
|
179
277
|
sceneCount: z.number().int().min(1).max(12).optional().describe("기본 6"),
|
|
278
|
+
researchBrief: z.string().optional().describe("research_product 결과의 planBrief — 불만·후킹·경쟁공백이 기획에 반영됨"),
|
|
180
279
|
},
|
|
181
|
-
wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6 }) => {
|
|
280
|
+
wrapCloudHandler(config, async ({ productName, productImagePaths, modelImagePath = "", description = "", targetCustomer = "", tone = "자동 추천", categoryId = "ecommerce", sceneCount = 6, researchBrief = "" }) => {
|
|
182
281
|
// 확장자 검증(임의 파일 업로드 차단) + 합산 크기 예산(서버 413 사전 차단).
|
|
183
282
|
for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "이미지" });
|
|
184
283
|
if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 이미지" });
|
|
@@ -192,6 +291,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
192
291
|
operationId: createUsageEventId("commerce-plan"),
|
|
193
292
|
productImages: productImagePaths.map((filePath) => fileToImagePayload(filePath)),
|
|
194
293
|
productName,
|
|
294
|
+
researchBrief,
|
|
195
295
|
sceneCount,
|
|
196
296
|
selectedCharacters: [],
|
|
197
297
|
skillOverride: null,
|
|
@@ -214,7 +314,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
214
314
|
|
|
215
315
|
server.tool(
|
|
216
316
|
"generate_scene_image",
|
|
217
|
-
"씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요.
|
|
317
|
+
"씬 이미지를 생성합니다 (기획 결과의 imagePrompt 사용). 반환된 imageUrl을 씬 영상 생성의 입력으로 쓰세요. 인물·제품 일관성은 이 참조 방식이 권장 경로입니다: 첫 씬(또는 캐릭터 시트/제품 사진)의 이미지를 referenceImageUrls·referenceImagePaths로 모든 씬에 앵커로 전달하세요.",
|
|
218
318
|
{
|
|
219
319
|
prompt: z.string().describe("이미지 프롬프트 (plan 결과의 imagePrompt)"),
|
|
220
320
|
aspectRatio: z.string().optional().describe("기본 9:16"),
|
|
@@ -1001,196 +1101,6 @@ export function registerCloudTools(server, config, api) {
|
|
|
1001
1101
|
}),
|
|
1002
1102
|
);
|
|
1003
1103
|
|
|
1004
|
-
// ── 인플루언서(소울) 스튜디오 ────────────────────────────────────────────────
|
|
1005
|
-
server.tool(
|
|
1006
|
-
"create_influencer_character",
|
|
1007
|
-
"참조 사진으로 인플루언서(소울) 캐릭터를 학습 등록합니다. 학습 비용 $2.50(1회성) — 실패 시 자동 재시도 금지(중복 과금). 등록 후 influencer_character_status로 completed까지 10초 간격 폴링하세요 (수 분 소요).",
|
|
1008
|
-
{
|
|
1009
|
-
name: z.string().min(1).describe("캐릭터 이름"),
|
|
1010
|
-
photoPaths: z.array(z.string()).min(5).max(18).describe("동일 인물 사진 로컬 경로 5~18장 (jpg/png/webp)"),
|
|
1011
|
-
},
|
|
1012
|
-
wrapCloudHandler(config, async ({ name, photoPaths }) => {
|
|
1013
|
-
for (const filePath of photoPaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 사진" });
|
|
1014
|
-
const publicUrls = [];
|
|
1015
|
-
// 업로드 URL 발급은 사진 수만큼 쓰기 슬롯(20/분)을 쓰므로, 분당 한도에 걸리면
|
|
1016
|
-
// retryAfterSeconds 만큼 대기 후 이어서 진행한다(발급된 URL 은 보존).
|
|
1017
|
-
const issueUploadUrl = async (mime) => {
|
|
1018
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
1019
|
-
try {
|
|
1020
|
-
return await api.request("/api/tracker/higgsfield/files/generate-upload-url", {
|
|
1021
|
-
body: { content_type: mime },
|
|
1022
|
-
method: "POST",
|
|
1023
|
-
timeoutMs: 30_000,
|
|
1024
|
-
});
|
|
1025
|
-
} catch (error) {
|
|
1026
|
-
if (error instanceof AimakeallApiError && error.errorCode === "MACHINE_RATE_LIMITED" && attempt < 3) {
|
|
1027
|
-
await new Promise((resolve) => setTimeout(resolve, Math.max(1, error.retryAfterSeconds || 10) * 1000));
|
|
1028
|
-
continue;
|
|
1029
|
-
}
|
|
1030
|
-
throw error;
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
};
|
|
1034
|
-
// presigned PUT(직접) — 컴패니언 불필요.
|
|
1035
|
-
for (const filePath of photoPaths) {
|
|
1036
|
-
const mime = guessMimeType(filePath, "image/jpeg");
|
|
1037
|
-
const issued = await issueUploadUrl(mime);
|
|
1038
|
-
const uploadUrl = String(issued?.upload_url || "");
|
|
1039
|
-
const publicUrl = String(issued?.public_url || "");
|
|
1040
|
-
if (!uploadUrl || !publicUrl) {
|
|
1041
|
-
return textResult("업로드 URL 발급에 실패했습니다. Higgsfield API 키 연결을 확인하세요.", { isError: true });
|
|
1042
|
-
}
|
|
1043
|
-
const put = await fetch(uploadUrl, {
|
|
1044
|
-
body: readFileSync(filePath),
|
|
1045
|
-
headers: { "Content-Type": mime },
|
|
1046
|
-
method: "PUT",
|
|
1047
|
-
signal: AbortSignal.timeout(120_000),
|
|
1048
|
-
});
|
|
1049
|
-
if (!put.ok) {
|
|
1050
|
-
return textResult(`사진 업로드 실패 (HTTP ${put.status}): ${path.basename(filePath)}`, { isError: true });
|
|
1051
|
-
}
|
|
1052
|
-
publicUrls.push(publicUrl);
|
|
1053
|
-
}
|
|
1054
|
-
let payload;
|
|
1055
|
-
try {
|
|
1056
|
-
// 서버의 업스트림 창(90s)보다 길게 잡아, 클라이언트가 먼저 끊어 "성공했는데
|
|
1057
|
-
// 실패로 오인 → 재시도 → 중복 과금($2.50)" 되는 상황을 방지한다.
|
|
1058
|
-
payload = await api.request("/api/tracker/higgsfield/v1/custom-references", {
|
|
1059
|
-
body: { input_images: publicUrls.map((url) => ({ image_url: url, type: "image_url" })), name },
|
|
1060
|
-
method: "POST",
|
|
1061
|
-
timeoutMs: 120_000,
|
|
1062
|
-
});
|
|
1063
|
-
} catch (error) {
|
|
1064
|
-
const reason = String(error?.message || error);
|
|
1065
|
-
return textResult(
|
|
1066
|
-
`캐릭터 학습 등록 요청이 실패했습니다: ${reason}\n재시도 전에 반드시 list_influencer_characters로 캐릭터가 이미 생성됐는지 확인하세요 — 생성돼 있다면 재시도는 중복 과금($2.50)입니다.`,
|
|
1067
|
-
{ isError: true },
|
|
1068
|
-
);
|
|
1069
|
-
}
|
|
1070
|
-
const referenceId = String(payload?.id || payload?.reference_id || "");
|
|
1071
|
-
if (!referenceId) return textResult("캐릭터 학습 등록 응답에 ID가 없습니다. list_influencer_characters로 생성 여부를 확인하세요.", { isError: true });
|
|
1072
|
-
return jsonResult({
|
|
1073
|
-
costUsd: payload?.costUsd,
|
|
1074
|
-
next: "influencer_character_status를 10초 간격으로 폴링해 completed를 기다리세요. 이 호출을 재시도하면 중복 과금됩니다.",
|
|
1075
|
-
photoCount: publicUrls.length,
|
|
1076
|
-
referenceId,
|
|
1077
|
-
status: payload?.status,
|
|
1078
|
-
});
|
|
1079
|
-
}),
|
|
1080
|
-
);
|
|
1081
|
-
|
|
1082
|
-
server.tool(
|
|
1083
|
-
"influencer_character_status",
|
|
1084
|
-
"소울 캐릭터 학습 상태를 조회합니다 (terminal: completed | failed).",
|
|
1085
|
-
{ referenceId: z.string().min(1) },
|
|
1086
|
-
wrapCloudHandler(config, async ({ referenceId }) => {
|
|
1087
|
-
const payload = await api.request(`/api/tracker/higgsfield/v1/custom-references/${encodeURIComponent(referenceId)}`, {
|
|
1088
|
-
method: "GET",
|
|
1089
|
-
timeoutMs: 30_000,
|
|
1090
|
-
});
|
|
1091
|
-
return jsonResult({
|
|
1092
|
-
name: payload?.name,
|
|
1093
|
-
referenceId,
|
|
1094
|
-
status: payload?.status,
|
|
1095
|
-
thumbnailUrl: payload?.thumbnail_url,
|
|
1096
|
-
});
|
|
1097
|
-
}),
|
|
1098
|
-
);
|
|
1099
|
-
|
|
1100
|
-
server.tool(
|
|
1101
|
-
"list_influencer_characters",
|
|
1102
|
-
"등록된 소울 캐릭터 목록을 조회합니다. (machine 토큰은 서버 경유로 만든 캐릭터만 상세 조회·생성에 사용 가능)",
|
|
1103
|
-
{ page: z.number().int().min(1).optional().describe("기본 1") },
|
|
1104
|
-
wrapCloudHandler(config, async ({ page = 1 }) => {
|
|
1105
|
-
const payload = await api.request(`/api/tracker/higgsfield/v1/custom-references/list?page=${page}&page_size=50`, {
|
|
1106
|
-
method: "GET",
|
|
1107
|
-
timeoutMs: 30_000,
|
|
1108
|
-
});
|
|
1109
|
-
return jsonResult({
|
|
1110
|
-
characters: (Array.isArray(payload?.items) ? payload.items : []).map((item) => ({
|
|
1111
|
-
id: item?.id,
|
|
1112
|
-
name: item?.name,
|
|
1113
|
-
status: item?.status,
|
|
1114
|
-
})),
|
|
1115
|
-
page,
|
|
1116
|
-
totalPages: payload?.total_pages,
|
|
1117
|
-
});
|
|
1118
|
-
}),
|
|
1119
|
-
);
|
|
1120
|
-
|
|
1121
|
-
server.tool(
|
|
1122
|
-
"suggest_influencer_prompts",
|
|
1123
|
-
"소울 이미지용 연출·프롬프트 10종을 추천받습니다. 반환 prompt는 부정규칙까지 완성형이므로 generate_influencer_image에 그대로 쓰세요.",
|
|
1124
|
-
{
|
|
1125
|
-
gender: z.enum(["여자", "남자"]),
|
|
1126
|
-
ageRange: z.enum(["10대", "20대", "30대", "40대", "50대", "60대"]),
|
|
1127
|
-
keyword: z.string().min(1).describe("장면 키워드 (예: 카페에서 책 읽는 모습)"),
|
|
1128
|
-
},
|
|
1129
|
-
wrapCloudHandler(config, async ({ gender, ageRange, keyword }) => {
|
|
1130
|
-
const { events } = await api.requestSse("/api/tracker/gemini/soul-prompts", {
|
|
1131
|
-
body: { ageRange, gender, imageModel: "soul", keyword },
|
|
1132
|
-
timeoutMs: 180_000,
|
|
1133
|
-
});
|
|
1134
|
-
const items = events
|
|
1135
|
-
.filter((event) => event?.type === "item" && event.item)
|
|
1136
|
-
.map((event) => ({ directing: event.item.directing, id: event.item.id, prompt: event.item.prompt }));
|
|
1137
|
-
if (!items.length) return textResult("프롬프트 추천 결과가 비어 있습니다.", { isError: true });
|
|
1138
|
-
return jsonResult({ count: items.length, items });
|
|
1139
|
-
}),
|
|
1140
|
-
);
|
|
1141
|
-
|
|
1142
|
-
server.tool(
|
|
1143
|
-
"generate_influencer_image",
|
|
1144
|
-
"학습 완료(completed)된 소울 캐릭터로 인플루언서 이미지를 생성합니다 (720p $0.094/장, 1080p $0.19/장). 반환 requestId를 influencer_image_status로 폴링하세요 (~30초).",
|
|
1145
|
-
{
|
|
1146
|
-
prompt: z.string().min(1).describe("영문 프롬프트 (suggest_influencer_prompts 결과 그대로 권장)"),
|
|
1147
|
-
referenceId: z.string().min(1).describe("completed 상태의 캐릭터 referenceId"),
|
|
1148
|
-
aspectRatio: z.enum(["9:16", "16:9", "4:3", "3:4", "1:1", "2:3", "3:2"]).optional().describe("기본 4:3"),
|
|
1149
|
-
resolution: z.enum(["720p", "1080p"]).optional().describe("기본 720p"),
|
|
1150
|
-
imageReferenceUrl: z.string().optional().describe("구도/씬 참조 이미지 URL (선택)"),
|
|
1151
|
-
seed: z.number().int().optional().describe("재현용 시드 (선택)"),
|
|
1152
|
-
},
|
|
1153
|
-
wrapCloudHandler(config, async ({ prompt, referenceId, aspectRatio = "4:3", resolution = "720p", imageReferenceUrl = "", seed }) => {
|
|
1154
|
-
const payload = await api.request("/api/tracker/higgsfield/higgsfield-ai/soul/character", {
|
|
1155
|
-
body: {
|
|
1156
|
-
aspect_ratio: aspectRatio,
|
|
1157
|
-
batch_size: 1,
|
|
1158
|
-
custom_reference_id: referenceId,
|
|
1159
|
-
custom_reference_strength: 1,
|
|
1160
|
-
enhance_prompt: false,
|
|
1161
|
-
...(imageReferenceUrl ? { image_reference_url: imageReferenceUrl } : {}),
|
|
1162
|
-
prompt,
|
|
1163
|
-
resolution,
|
|
1164
|
-
...(seed !== undefined ? { seed } : {}),
|
|
1165
|
-
},
|
|
1166
|
-
method: "POST",
|
|
1167
|
-
timeoutMs: 60_000,
|
|
1168
|
-
});
|
|
1169
|
-
const requestId = String(payload?.request_id || payload?.id || "");
|
|
1170
|
-
if (!requestId) return textResult("이미지 생성 요청 응답에 request_id가 없습니다.", { isError: true });
|
|
1171
|
-
return jsonResult({ costUsd: payload?.costUsd, requestId, status: payload?.status });
|
|
1172
|
-
}),
|
|
1173
|
-
);
|
|
1174
|
-
|
|
1175
|
-
server.tool(
|
|
1176
|
-
"influencer_image_status",
|
|
1177
|
-
"소울 이미지 생성 상태를 조회합니다 (terminal: completed | failed | nsfw | canceled). 결과 이미지는 만료 없는 공개 URL입니다.",
|
|
1178
|
-
{ requestId: z.string().min(1) },
|
|
1179
|
-
wrapCloudHandler(config, async ({ requestId }) => {
|
|
1180
|
-
const payload = await api.request(`/api/tracker/higgsfield/requests/${encodeURIComponent(requestId)}/status`, {
|
|
1181
|
-
method: "GET",
|
|
1182
|
-
timeoutMs: 30_000,
|
|
1183
|
-
});
|
|
1184
|
-
return jsonResult({
|
|
1185
|
-
error: payload?.error || payload?.message || undefined,
|
|
1186
|
-
imageUrls: (Array.isArray(payload?.images) ? payload.images : []).map((image) => image?.url).filter(Boolean),
|
|
1187
|
-
progress: payload?.progress,
|
|
1188
|
-
requestId,
|
|
1189
|
-
status: payload?.status,
|
|
1190
|
-
});
|
|
1191
|
-
}),
|
|
1192
|
-
);
|
|
1193
|
-
|
|
1194
1104
|
// ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
|
|
1195
1105
|
server.tool(
|
|
1196
1106
|
"generate_pdp",
|
|
@@ -1252,30 +1162,33 @@ export function registerCloudTools(server, config, api) {
|
|
|
1252
1162
|
for (const image of Array.isArray(result.lifestyleImages) ? result.lifestyleImages : []) {
|
|
1253
1163
|
const url = String(image?.url || "");
|
|
1254
1164
|
if (!url) continue;
|
|
1165
|
+
// HTML 이 실제로 참조하는 컷만 받는다 — 조립에서 빠진 컷을 받아 저장하면
|
|
1166
|
+
// 정리되지 않는 exports/ 에 고아 파일이 영구히 쌓인다.
|
|
1167
|
+
const escaped = escapeHtml(url);
|
|
1168
|
+
if (!html.includes(url) && !html.includes(escaped)) continue;
|
|
1255
1169
|
try {
|
|
1256
1170
|
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
1257
1171
|
if (!res.ok) continue;
|
|
1258
1172
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
1259
1173
|
const ext = String(res.headers.get("content-type") || "").includes("png") ? "png" : "jpg";
|
|
1260
|
-
const saved =
|
|
1174
|
+
const saved = saveExportBuffer(config.stateDir, `pdp-image-${image?.idx ?? savedImages.length + 1}.${ext}`, buf);
|
|
1261
1175
|
// HTML 은 같은 디렉터리에 저장되므로 상대경로 치환으로 자립형 유지.
|
|
1262
1176
|
const local = `./${path.basename(saved.filePath)}`;
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
if (html !== before) savedImages.push(saved.filePath);
|
|
1177
|
+
html = html.split(url).join(local).split(escaped).join(local);
|
|
1178
|
+
savedImages.push(saved.filePath);
|
|
1266
1179
|
} catch {
|
|
1267
1180
|
// 다운로드 실패한 컷은 원격 URL 유지
|
|
1268
1181
|
}
|
|
1269
1182
|
}
|
|
1270
1183
|
}
|
|
1271
|
-
const savedHtml =
|
|
1184
|
+
const savedHtml = saveExportBuffer(config.stateDir, "pdp.html", Buffer.from(html, "utf8"), { contentType: "text/html" });
|
|
1272
1185
|
return jsonResult({
|
|
1273
1186
|
analysis: trimAnalysis(result.analysis),
|
|
1274
1187
|
heroHeadline: result?.copy?.heroHeadline,
|
|
1275
1188
|
htmlPath: savedHtml.filePath,
|
|
1276
1189
|
imageCount: (Array.isArray(result.lifestyleImages) ? result.lifestyleImages : []).filter((image) => image?.url).length,
|
|
1277
1190
|
localImages: savedImages.length,
|
|
1278
|
-
note: "htmlPath와 로컬
|
|
1191
|
+
note: "htmlPath와 로컬 이미지는 자동 정리되지 않는 exports 폴더에 저장됩니다. 옮길 때는 같은 폴더의 이미지들과 함께 이동하세요(상대경로 참조).",
|
|
1279
1192
|
tagline: result?.copy?.tagline,
|
|
1280
1193
|
});
|
|
1281
1194
|
}),
|
package/lib/config.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
// 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
|
|
5
|
-
export const MCP_PROXY_VERSION = "0.
|
|
5
|
+
export const MCP_PROXY_VERSION = "0.4.1";
|
|
6
6
|
|
|
7
7
|
export const DEFAULT_API_BASE = "https://aimakeall.com";
|
|
8
8
|
export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
|
package/lib/media-store.mjs
CHANGED
|
@@ -114,6 +114,17 @@ export function readPayloadHandle(filePath, { stateDir = "" } = {}) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
// 보존 기간이 지난 스테이징 미디어 정리 — 숨김 디렉터리에 GB 단위로 쌓이는 것 방지.
|
|
117
|
+
// 최종 산출물(PDP HTML 등)용 보존 디렉터리 — pruneOldMedia(48h TTL)는 media/ 만
|
|
118
|
+
// 정리하므로 exports/ 는 자동 삭제되지 않는다.
|
|
119
|
+
export function saveExportBuffer(stateDir, name, buffer, { contentType = "" } = {}) {
|
|
120
|
+
const dir = path.join(stateDir, "exports");
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
const safeName = sanitizeOutputFilename(name, "export");
|
|
123
|
+
const filePath = path.join(dir, `${Date.now()}-${randomUUID().slice(0, 8)}-${safeName}`);
|
|
124
|
+
writeFileSync(filePath, buffer, { flag: "wx", mode: 0o600 });
|
|
125
|
+
return { bytes: buffer.length, contentType, filePath };
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
export function pruneOldMedia(stateDir, { now = Date.now() } = {}) {
|
|
118
129
|
const dir = mediaDir(stateDir);
|
|
119
130
|
let removed = 0;
|