@tomako/tools-runtime 0.1.5 → 0.1.7
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/package.json +2 -1
- package/src/components/tools/engine/package-widget-runtime.tsx +5 -1
- package/src/components/tools/engine/task-recovery.test.ts +65 -0
- package/src/components/tools/engine/task-recovery.ts +109 -0
- package/src/components/tools/engine/use-legacy-llm-task.ts +16 -3
- package/src/components/tools/engine/use-tool-task.ts +77 -53
- package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx +88 -16
- package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.test.ts +52 -0
- package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.ts +3 -2
- package/src/features/tools/app-store-screenshot-generator/widget/generation.test.ts +126 -0
- package/src/features/tools/app-store-screenshot-generator/widget/generation.ts +52 -14
- package/src/features/tools/app-store-screenshot-generator/widget/input-media.test.ts +81 -0
- package/src/features/tools/app-store-screenshot-generator/widget/input-media.ts +20 -8
- package/src/features/tools/cold-start-channel-selector/widget/cold-start-channel-selector.tsx +45 -16
- package/src/features/tools/competitor-analysis/competitor-analysis.container.tsx +26 -20
- package/src/features/tools/competitor-analysis/widget/competitor-analysis.tsx +47 -16
- package/src/features/tools/disclaimer-generator/disclaimer-generator.package.ts +3 -3
- package/src/features/tools/email-template-generator/widget/email-template-generator.tsx +37 -4
- package/src/features/tools/package/to-tool-module.ts +15 -0
- package/src/features/tools/product-name-generator/widget/product-name-generator.tsx +60 -27
- package/src/features/tools/product-poster-generator/widget/constants.ts +3 -1
- package/src/features/tools/product-poster-generator/widget/helpers.test.ts +51 -0
- package/src/features/tools/product-poster-generator/widget/helpers.ts +27 -0
- package/src/features/tools/product-poster-generator/widget/product-poster-generator.tsx +112 -12
- package/src/features/tools/slogan-generator/slogan-generator.package.ts +28 -28
- package/src/i18n/messages/en/tools/app-icon-resizer.ts +1 -1
- package/src/i18n/messages/en/tools/app-store-screenshot-generator.ts +2 -2
- package/src/i18n/messages/en/tools/cold-start-channel-selector.ts +4 -4
- package/src/i18n/messages/en/tools/competitor-analysis.ts +9 -8
- package/src/i18n/messages/en/tools/email-template-generator.ts +10 -10
- package/src/i18n/messages/en/tools/product-poster-generator.ts +1 -0
- package/src/i18n/messages/zh/tools/app-store-screenshot-generator.ts +3 -3
- package/src/i18n/messages/zh/tools/cold-start-channel-selector.ts +2 -2
- package/src/i18n/messages/zh/tools/competitor-analysis.ts +10 -9
- package/src/i18n/messages/zh/tools/email-template-generator.ts +11 -11
- package/src/i18n/messages/zh/tools/product-poster-generator.ts +1 -0
- package/src/i18n/messages/zh-tw/tools/app-store-screenshot-generator.ts +44 -44
- package/src/i18n/messages/zh-tw/tools/cold-start-channel-selector.ts +105 -105
- package/src/i18n/messages/zh-tw/tools/competitor-analysis.ts +41 -40
- package/src/i18n/messages/zh-tw/tools/email-template-generator.ts +111 -111
- package/src/i18n/messages/zh-tw/tools/product-name-generator.ts +92 -92
- package/src/i18n/messages/zh-tw/tools/product-poster-generator.ts +74 -73
- package/src/i18n/messages/zh-tw/tools/twitter-gif-downloader.ts +47 -47
- package/src/lib/tools/cold-start-channel-selector-schema.test.ts +39 -0
- package/src/lib/tools/cold-start-channel-selector-schema.ts +8 -4
- package/src/lib/tools/competitor-analysis-schema.test.ts +69 -0
- package/src/lib/tools/competitor-analysis-schema.ts +53 -5
|
@@ -40,9 +40,9 @@ export const disclaimerGeneratorPackage: ToolPackage = {
|
|
|
40
40
|
},
|
|
41
41
|
},
|
|
42
42
|
copy: {
|
|
43
|
-
title: t("Disclaimer Generator", "
|
|
43
|
+
title: t("Website Disclaimer Generator", "网站免责声明生成器", "網站免責聲明產生器"),
|
|
44
44
|
description: t(
|
|
45
|
-
"
|
|
45
|
+
"Draft a website, affiliate, review, or professional-information disclaimer with placement guidance, missing facts, risk flags, and a review checklist.",
|
|
46
46
|
"根据业务和发布场景生成网站、联盟、评测或专业信息免责声明草案,并提供放置建议、缺失信息、风险提示与人工复核清单。",
|
|
47
47
|
"依照業務與發布情境產生網站、聯盟、評測或專業資訊免責聲明草案,並提供放置建議、缺漏資訊、風險提示與人工複核清單。",
|
|
48
48
|
),
|
|
@@ -252,7 +252,7 @@ export const disclaimerGeneratorPackage: ToolPackage = {
|
|
|
252
252
|
},
|
|
253
253
|
page: {
|
|
254
254
|
workspace: {
|
|
255
|
-
heading: t("
|
|
255
|
+
heading: t("Website disclaimer generator with review guidance", "网站免责声明生成器:草案与复核指引", "網站免責聲明產生器:草案與複核指引"),
|
|
256
256
|
hint: t(
|
|
257
257
|
"Describe your content and where it appears. Get a copyable draft, suggested placement, gaps to confirm, and a review checklist before you publish.",
|
|
258
258
|
"描述你的内容和发布位置,获得可复制草案、建议放置位置、待确认缺口和发布前复核清单。",
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { ChevronDown, Loader2 } from "lucide-react";
|
|
4
|
-
import { useRef, useState } from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
|
|
6
6
|
import { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
|
|
7
|
+
import {
|
|
8
|
+
persistToolTaskRecovery,
|
|
9
|
+
readToolTaskRecovery,
|
|
10
|
+
} from "../../../../components/tools/engine/task-recovery";
|
|
7
11
|
import { copyTextToClipboard } from "../../../../components/tools/workspace/tool-result-file-actions";
|
|
8
12
|
import { Button } from "@tomako/ui/button";
|
|
9
13
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@tomako/ui/collapsible";
|
|
@@ -38,6 +42,8 @@ import { defaultForm, fileBase, isPlaceholder, wait } from "./helpers";
|
|
|
38
42
|
import { EmailPreviewPanel } from "./preview-panel";
|
|
39
43
|
import type { CopyMap, ErrorCategory, FormState, WidgetState } from "./types";
|
|
40
44
|
|
|
45
|
+
const RECOVERY_SLUG = "email-template-generator";
|
|
46
|
+
|
|
41
47
|
export function EmailTemplateGenerator() {
|
|
42
48
|
const t = useTranslations("toolsPages.emailTemplateGenerator.widget");
|
|
43
49
|
const locale = useLocale() as AppLocale;
|
|
@@ -76,8 +82,17 @@ export function EmailTemplateGenerator() {
|
|
|
76
82
|
setState({ phase: "error", category, taskId, previous });
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
function startWatch(
|
|
80
|
-
|
|
85
|
+
function startWatch(
|
|
86
|
+
taskId: string,
|
|
87
|
+
previous?: EmailTemplateResult,
|
|
88
|
+
startedAtMs?: number,
|
|
89
|
+
) {
|
|
90
|
+
setState({
|
|
91
|
+
phase: "running",
|
|
92
|
+
status: startedAtMs ? "RESTORING" : "PENDING",
|
|
93
|
+
taskId,
|
|
94
|
+
previous,
|
|
95
|
+
});
|
|
81
96
|
void wait(80).then(() => {
|
|
82
97
|
resultRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
83
98
|
});
|
|
@@ -103,10 +118,26 @@ export function EmailTemplateGenerator() {
|
|
|
103
118
|
{
|
|
104
119
|
pollIntervalMs: 3500,
|
|
105
120
|
graceWindowMs: 300_000,
|
|
121
|
+
startedAtMs,
|
|
106
122
|
},
|
|
107
123
|
);
|
|
108
124
|
}
|
|
109
125
|
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
|
|
128
|
+
parseInput: (value) => {
|
|
129
|
+
const parsed = emailTemplateInputSchema.safeParse(value);
|
|
130
|
+
return parsed.success ? parsed.data : null;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
if (!recovered) return;
|
|
134
|
+
setForm(recovered.input);
|
|
135
|
+
settledRef.current = false;
|
|
136
|
+
startWatch(recovered.taskId, undefined, recovered.startedAtMs);
|
|
137
|
+
// Restore only once for this mounted tool route.
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [taskWatch]);
|
|
140
|
+
|
|
110
141
|
function validateInput(): EmailTemplateInput | null {
|
|
111
142
|
const parsed = emailTemplateInputSchema.safeParse(form);
|
|
112
143
|
if (!parsed.success || isPlaceholder(form.productName) || isPlaceholder(form.brief)) {
|
|
@@ -126,7 +157,9 @@ export function EmailTemplateGenerator() {
|
|
|
126
157
|
|
|
127
158
|
try {
|
|
128
159
|
const submitted = await submitEmailTemplate(parsed, locale);
|
|
129
|
-
|
|
160
|
+
const startedAtMs = Date.now();
|
|
161
|
+
persistToolTaskRecovery(RECOVERY_SLUG, submitted.taskId, parsed, startedAtMs);
|
|
162
|
+
startWatch(submitted.taskId, undefined, startedAtMs);
|
|
130
163
|
} catch {
|
|
131
164
|
finishError("submit");
|
|
132
165
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ToolModule } from "../types";
|
|
2
|
+
import type { AppLocale } from "../../../i18n/locale";
|
|
3
|
+
import { pickL10n } from "./l10n";
|
|
2
4
|
import { loadToolPackage } from "./package-loaders";
|
|
3
5
|
import { createPackageContainer } from "./package-container";
|
|
4
6
|
import { createPackageToolSpec } from "./to-tool-spec";
|
|
@@ -18,3 +20,16 @@ export async function loadPackageToolModule(
|
|
|
18
20
|
const pkg = await loadToolPackage(slug);
|
|
19
21
|
return pkg ? createPackageToolModule(pkg) : undefined;
|
|
20
22
|
}
|
|
23
|
+
|
|
24
|
+
/** Package FAQ data lives beside the package page, not in legacy toolsPages messages. */
|
|
25
|
+
export async function loadPackageToolFaqItems(
|
|
26
|
+
slug: string,
|
|
27
|
+
locale: AppLocale,
|
|
28
|
+
): Promise<Array<{ question: string; answer: string }> | undefined> {
|
|
29
|
+
const pkg = await loadToolPackage(slug);
|
|
30
|
+
if (!pkg) return undefined;
|
|
31
|
+
return pkg.page.faq.map((item) => ({
|
|
32
|
+
question: pickL10n(item.question, locale),
|
|
33
|
+
answer: pickL10n(item.answer, locale),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
@@ -39,6 +39,11 @@ import { ChevronDown, Lightbulb } from "lucide-react";
|
|
|
39
39
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
|
40
40
|
|
|
41
41
|
import { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
|
|
42
|
+
import {
|
|
43
|
+
clearToolTaskRecovery,
|
|
44
|
+
persistToolTaskRecovery,
|
|
45
|
+
readToolTaskRecovery,
|
|
46
|
+
} from "../../../../components/tools/engine/task-recovery";
|
|
42
47
|
import {
|
|
43
48
|
ToolActionBar,
|
|
44
49
|
ToolEmptyPanel,
|
|
@@ -80,6 +85,8 @@ import type {
|
|
|
80
85
|
ScreenState,
|
|
81
86
|
} from "./types";
|
|
82
87
|
|
|
88
|
+
const RECOVERY_SLUG = "product-name-generator";
|
|
89
|
+
|
|
83
90
|
export function ProductNameGenerator() {
|
|
84
91
|
const locale = useLocale();
|
|
85
92
|
const t = useTranslations("toolsPages.productNameGenerator.widget");
|
|
@@ -111,6 +118,21 @@ export function ProductNameGenerator() {
|
|
|
111
118
|
};
|
|
112
119
|
}, [screenTaskWatch]);
|
|
113
120
|
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
|
|
123
|
+
parseInput: (value) => {
|
|
124
|
+
const parsed = productNameInputSchema.safeParse(value);
|
|
125
|
+
return parsed.success ? parsed.data : null;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
if (!recovered) return;
|
|
129
|
+
setForm(recovered.input);
|
|
130
|
+
activeScreenTaskIdRef.current = recovered.taskId;
|
|
131
|
+
watchScreenTask(recovered.taskId, "RESTORING", recovered.startedAtMs);
|
|
132
|
+
// Restore only once for this mounted tool route.
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
}, [screenTaskWatch]);
|
|
135
|
+
|
|
114
136
|
const copy = useMemo(
|
|
115
137
|
() =>
|
|
116
138
|
({
|
|
@@ -320,6 +342,40 @@ export function ProductNameGenerator() {
|
|
|
320
342
|
};
|
|
321
343
|
}
|
|
322
344
|
|
|
345
|
+
function watchScreenTask(
|
|
346
|
+
taskId: string,
|
|
347
|
+
status: string,
|
|
348
|
+
startedAtMs?: number,
|
|
349
|
+
) {
|
|
350
|
+
activeScreenTaskIdRef.current = taskId;
|
|
351
|
+
setScreenState({ phase: "loading", status, taskId });
|
|
352
|
+
screenTaskWatch.start(
|
|
353
|
+
taskId,
|
|
354
|
+
pollScreenOnce,
|
|
355
|
+
{
|
|
356
|
+
onStatus: (nextStatus) => {
|
|
357
|
+
setScreenState({ phase: "loading", status: nextStatus, taskId });
|
|
358
|
+
},
|
|
359
|
+
onSuccess: (data) => {
|
|
360
|
+
setScreenState({ phase: "done", data });
|
|
361
|
+
setDetailStates({});
|
|
362
|
+
},
|
|
363
|
+
onError: (category) => {
|
|
364
|
+
setScreenState({
|
|
365
|
+
phase: "error",
|
|
366
|
+
message:
|
|
367
|
+
category === "schema"
|
|
368
|
+
? t("errors.invalidResult")
|
|
369
|
+
: category === "timeout"
|
|
370
|
+
? t("errors.noResult")
|
|
371
|
+
: t("errors.agent"),
|
|
372
|
+
});
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
{ graceWindowMs: 300_000, startedAtMs },
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
323
379
|
async function generateWithAgent() {
|
|
324
380
|
const validatedForm = validateForm();
|
|
325
381
|
if (!validatedForm || !canSubmit) return;
|
|
@@ -337,33 +393,9 @@ export function ProductNameGenerator() {
|
|
|
337
393
|
locale,
|
|
338
394
|
);
|
|
339
395
|
console.info("[workspace-tool-qa] product-name-generator", taskId);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
taskId,
|
|
344
|
-
pollScreenOnce,
|
|
345
|
-
{
|
|
346
|
-
onStatus: (nextStatus) => {
|
|
347
|
-
setScreenState({ phase: "loading", status: nextStatus, taskId });
|
|
348
|
-
},
|
|
349
|
-
onSuccess: (data) => {
|
|
350
|
-
setScreenState({ phase: "done", data });
|
|
351
|
-
setDetailStates({});
|
|
352
|
-
},
|
|
353
|
-
onError: (category) => {
|
|
354
|
-
setScreenState({
|
|
355
|
-
phase: "error",
|
|
356
|
-
message:
|
|
357
|
-
category === "schema"
|
|
358
|
-
? t("errors.invalidResult")
|
|
359
|
-
: category === "timeout"
|
|
360
|
-
? t("errors.noResult")
|
|
361
|
-
: t("errors.agent"),
|
|
362
|
-
});
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
|
-
{ graceWindowMs: 300_000 },
|
|
366
|
-
);
|
|
396
|
+
const startedAtMs = Date.now();
|
|
397
|
+
persistToolTaskRecovery(RECOVERY_SLUG, taskId, validatedForm, startedAtMs);
|
|
398
|
+
watchScreenTask(taskId, status, startedAtMs);
|
|
367
399
|
} catch (err) {
|
|
368
400
|
setScreenState({ phase: "error", message: formatAgentError(err) });
|
|
369
401
|
}
|
|
@@ -460,6 +492,7 @@ export function ProductNameGenerator() {
|
|
|
460
492
|
function reset() {
|
|
461
493
|
screenTaskWatch.stop();
|
|
462
494
|
activeScreenTaskIdRef.current = null;
|
|
495
|
+
clearToolTaskRecovery(RECOVERY_SLUG);
|
|
463
496
|
setForm(defaultForm);
|
|
464
497
|
setScreenState({ phase: "idle" });
|
|
465
498
|
setDetailStates({});
|
|
@@ -122,4 +122,6 @@ export const BRAND_COLOR_PRESETS: readonly BrandColorPreset[] = [
|
|
|
122
122
|
] as const;
|
|
123
123
|
|
|
124
124
|
export const TASK_POLL_INTERVAL_MS = 2500;
|
|
125
|
-
|
|
125
|
+
// Match the Portal STREAMING timeout. The previous 5-minute client deadline
|
|
126
|
+
// abandoned valid image tasks while the server still allowed them to finish.
|
|
127
|
+
export const MAX_TASK_POLL_ATTEMPTS = 240;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { fetchProductPosterPng } from "./helpers";
|
|
5
|
+
|
|
6
|
+
test("fetchProductPosterPng fetches through the image proxy and returns a .png download", async () => {
|
|
7
|
+
let requestedUrl = "";
|
|
8
|
+
let requestedInit: RequestInit | undefined;
|
|
9
|
+
const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
10
|
+
requestedUrl = String(input);
|
|
11
|
+
requestedInit = init;
|
|
12
|
+
return new Response(new Uint8Array([137, 80, 78, 71]), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { "content-type": "image/png" },
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const file = await fetchProductPosterPng(
|
|
19
|
+
"https://cdn.example.com/generated/poster.png",
|
|
20
|
+
"example-product",
|
|
21
|
+
fetcher,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
assert.equal(
|
|
25
|
+
requestedUrl,
|
|
26
|
+
"/tools-api/image-download?url=https%3A%2F%2Fcdn.example.com%2Fgenerated%2Fposter.png",
|
|
27
|
+
);
|
|
28
|
+
assert.equal(requestedInit?.cache, "no-store");
|
|
29
|
+
assert.equal(file.fileName, "example-product-poster.png");
|
|
30
|
+
assert.equal(file.blob.type, "image/png");
|
|
31
|
+
assert.equal(file.blob.size, 4);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("fetchProductPosterPng rejects failed or non-PNG responses", async () => {
|
|
35
|
+
await assert.rejects(
|
|
36
|
+
fetchProductPosterPng("https://cdn.example.com/poster.png", "poster", async () =>
|
|
37
|
+
new Response(null, { status: 502 }),
|
|
38
|
+
),
|
|
39
|
+
/Poster download failed: 502/,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await assert.rejects(
|
|
43
|
+
fetchProductPosterPng("https://cdn.example.com/poster.webp", "poster", async () =>
|
|
44
|
+
new Response(new Uint8Array([1]), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "content-type": "image/webp" },
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
/Poster download returned image\/webp/,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ProductPosterGeneratorInput,
|
|
6
6
|
ProductPosterScene,
|
|
7
7
|
} from "../../../../lib/tools/product-poster-generator-schema";
|
|
8
|
+
import { imageDownloadUrl } from "../../../../lib/tools/image-download";
|
|
8
9
|
|
|
9
10
|
import { BRAND_COLOR_PRESETS, POSTER_INTENT_OPTIONS } from "./constants";
|
|
10
11
|
import type {
|
|
@@ -65,6 +66,32 @@ export function slugifyFileName(value: string) {
|
|
|
65
66
|
);
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
export async function fetchProductPosterPng(
|
|
70
|
+
imageUrl: string,
|
|
71
|
+
fileBaseName: string,
|
|
72
|
+
fetcher: typeof fetch = fetch,
|
|
73
|
+
) {
|
|
74
|
+
const response = await fetcher(imageDownloadUrl(imageUrl), { cache: "no-store" });
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Poster download failed: ${response.status}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
80
|
+
if (!contentType.startsWith("image/png")) {
|
|
81
|
+
throw new Error(`Poster download returned ${contentType || "an unknown content type"}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const blob = await response.blob();
|
|
85
|
+
if (blob.size === 0) {
|
|
86
|
+
throw new Error("Poster download returned an empty PNG");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
blob,
|
|
91
|
+
fileName: `${fileBaseName}-poster.png`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
export function sizeToDimensions(size: string) {
|
|
69
96
|
const [width, height] = size.split("x").map((part) => Number(part));
|
|
70
97
|
return {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { ChevronDown, FileImage } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
useMemo,
|
|
6
|
+
useEffect,
|
|
6
7
|
useRef,
|
|
7
8
|
useState,
|
|
8
9
|
type ChangeEvent,
|
|
@@ -22,8 +23,10 @@ import {
|
|
|
22
23
|
PRODUCT_POSTER_STYLES,
|
|
23
24
|
productPosterGeneratorInputSchema,
|
|
24
25
|
type ProductPosterFormat,
|
|
26
|
+
type ProductPosterGeneratorInput,
|
|
25
27
|
type ProductPosterStyle,
|
|
26
28
|
} from "../../../../lib/tools/product-poster-generator-schema";
|
|
29
|
+
import { downloadBlob } from "../../../../lib/file/download";
|
|
27
30
|
import { cn } from "@tomako/ui/cn";
|
|
28
31
|
import {
|
|
29
32
|
fetchProductPosterGenerationTask,
|
|
@@ -50,6 +53,11 @@ import {
|
|
|
50
53
|
toolTextareaClass,
|
|
51
54
|
type ToolLoadingStep,
|
|
52
55
|
} from "../../../../components/tools/workspace";
|
|
56
|
+
import {
|
|
57
|
+
clearToolTaskRecovery,
|
|
58
|
+
persistToolTaskRecovery,
|
|
59
|
+
readToolTaskRecovery,
|
|
60
|
+
} from "../../../../components/tools/engine/task-recovery";
|
|
53
61
|
import {
|
|
54
62
|
MAX_TASK_POLL_ATTEMPTS,
|
|
55
63
|
POSTER_FORMAT_OPTIONS,
|
|
@@ -70,6 +78,7 @@ import {
|
|
|
70
78
|
getPosterIntentId,
|
|
71
79
|
getPosterResultError,
|
|
72
80
|
getSafeErrorMessage,
|
|
81
|
+
fetchProductPosterPng,
|
|
73
82
|
loadingProgress,
|
|
74
83
|
loadingStepIndex,
|
|
75
84
|
readImageAsDataUrl,
|
|
@@ -86,6 +95,13 @@ import type {
|
|
|
86
95
|
ResultState,
|
|
87
96
|
} from "./types";
|
|
88
97
|
|
|
98
|
+
const RECOVERY_SLUG = "product-poster-generator";
|
|
99
|
+
|
|
100
|
+
type PosterTaskRecoveryInput = {
|
|
101
|
+
input: ProductPosterGeneratorInput;
|
|
102
|
+
settingsSummary: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
89
105
|
export function ProductPosterGenerator() {
|
|
90
106
|
const locale = useLocale();
|
|
91
107
|
const taskLocale = locale as AppLocale;
|
|
@@ -99,6 +115,8 @@ export function ProductPosterGenerator() {
|
|
|
99
115
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
|
100
116
|
const [formError, setFormError] = useState<string | null>(null);
|
|
101
117
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
118
|
+
const [downloading, setDownloading] = useState(false);
|
|
119
|
+
const [downloadError, setDownloadError] = useState<string | null>(null);
|
|
102
120
|
|
|
103
121
|
const formatLabels = t.raw("formatLabels") as Record<ProductPosterFormat, string>;
|
|
104
122
|
const styleLabels = t.raw("styleLabels") as Record<ProductPosterStyle, string>;
|
|
@@ -158,6 +176,39 @@ export function ProductPosterGenerator() {
|
|
|
158
176
|
|
|
159
177
|
const busy = result.phase === "submitting" || result.phase === "polling";
|
|
160
178
|
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const recovered = readToolTaskRecovery<PosterTaskRecoveryInput>(RECOVERY_SLUG, {
|
|
181
|
+
parseInput: (value) => {
|
|
182
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
183
|
+
const candidate = value as Partial<PosterTaskRecoveryInput>;
|
|
184
|
+
const parsed = productPosterGeneratorInputSchema.safeParse(candidate.input);
|
|
185
|
+
if (!parsed.success || typeof candidate.settingsSummary !== "string") return null;
|
|
186
|
+
return { input: parsed.data, settingsSummary: candidate.settingsSummary };
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
if (!recovered) return;
|
|
190
|
+
const generationId = generationSeqRef.current + 1;
|
|
191
|
+
generationSeqRef.current = generationId;
|
|
192
|
+
setForm({
|
|
193
|
+
...defaultForm,
|
|
194
|
+
...recovered.input.input,
|
|
195
|
+
screenshotNames: [],
|
|
196
|
+
screenshotDataUrls: [],
|
|
197
|
+
logoName: undefined,
|
|
198
|
+
logoDataUrl: undefined,
|
|
199
|
+
});
|
|
200
|
+
void watchPosterTask(
|
|
201
|
+
recovered.taskId,
|
|
202
|
+
recovered.input.input,
|
|
203
|
+
recovered.input.settingsSummary,
|
|
204
|
+
generationId,
|
|
205
|
+
recovered.startedAtMs,
|
|
206
|
+
"RESTORING",
|
|
207
|
+
);
|
|
208
|
+
// Restore only once for this mounted tool route.
|
|
209
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
161
212
|
function scrollToResult() {
|
|
162
213
|
window.requestAnimationFrame(() => {
|
|
163
214
|
resultRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
@@ -289,6 +340,7 @@ export function ProductPosterGenerator() {
|
|
|
289
340
|
}
|
|
290
341
|
|
|
291
342
|
setFormError(null);
|
|
343
|
+
setDownloadError(null);
|
|
292
344
|
setFieldErrors({});
|
|
293
345
|
const generationId = generationSeqRef.current + 1;
|
|
294
346
|
generationSeqRef.current = generationId;
|
|
@@ -320,9 +372,39 @@ export function ProductPosterGenerator() {
|
|
|
320
372
|
if (generationSeqRef.current !== generationId) return;
|
|
321
373
|
|
|
322
374
|
const taskId = submitted.taskId;
|
|
323
|
-
|
|
375
|
+
const startedAtMs = Date.now();
|
|
376
|
+
persistToolTaskRecovery<PosterTaskRecoveryInput>(
|
|
377
|
+
RECOVERY_SLUG,
|
|
378
|
+
taskId,
|
|
379
|
+
{ input: posterInput, settingsSummary: posterSettingsSummary },
|
|
380
|
+
startedAtMs,
|
|
381
|
+
);
|
|
382
|
+
await watchPosterTask(
|
|
383
|
+
taskId,
|
|
384
|
+
posterInput,
|
|
385
|
+
posterSettingsSummary,
|
|
386
|
+
generationId,
|
|
387
|
+
startedAtMs,
|
|
388
|
+
submitted.status,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
324
391
|
|
|
325
|
-
|
|
392
|
+
async function watchPosterTask(
|
|
393
|
+
taskId: string,
|
|
394
|
+
posterInput: ProductPosterGeneratorInput,
|
|
395
|
+
posterSettingsSummary: string,
|
|
396
|
+
generationId: number,
|
|
397
|
+
startedAtMs: number,
|
|
398
|
+
initialStatus: string,
|
|
399
|
+
) {
|
|
400
|
+
setResult({ phase: "polling", taskId, status: initialStatus });
|
|
401
|
+
const elapsedAttempts = Math.max(
|
|
402
|
+
0,
|
|
403
|
+
Math.floor((Date.now() - startedAtMs) / TASK_POLL_INTERVAL_MS),
|
|
404
|
+
);
|
|
405
|
+
const remainingAttempts = Math.max(1, MAX_TASK_POLL_ATTEMPTS - elapsedAttempts);
|
|
406
|
+
|
|
407
|
+
for (let attempt = 0; attempt < remainingAttempts; attempt += 1) {
|
|
326
408
|
try {
|
|
327
409
|
const task = await fetchProductPosterGenerationTask(taskId);
|
|
328
410
|
if (generationSeqRef.current !== generationId) return;
|
|
@@ -376,24 +458,40 @@ export function ProductPosterGenerator() {
|
|
|
376
458
|
}
|
|
377
459
|
}
|
|
378
460
|
|
|
379
|
-
function downloadPosterPng() {
|
|
380
|
-
if (result.phase !== "ready") return;
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
461
|
+
async function downloadPosterPng() {
|
|
462
|
+
if (result.phase !== "ready" || downloading) return;
|
|
463
|
+
const generationId = generationSeqRef.current;
|
|
464
|
+
setDownloading(true);
|
|
465
|
+
setDownloadError(null);
|
|
466
|
+
try {
|
|
467
|
+
const file = await fetchProductPosterPng(
|
|
468
|
+
result.data.imageUrl,
|
|
469
|
+
result.data.fileBaseName,
|
|
470
|
+
);
|
|
471
|
+
if (generationSeqRef.current !== generationId) return;
|
|
472
|
+
downloadBlob(file.blob, file.fileName);
|
|
473
|
+
} catch {
|
|
474
|
+
if (generationSeqRef.current === generationId) {
|
|
475
|
+
setDownloadError(t("errors.downloadFailed"));
|
|
476
|
+
}
|
|
477
|
+
} finally {
|
|
478
|
+
if (generationSeqRef.current === generationId) {
|
|
479
|
+
setDownloading(false);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
387
482
|
}
|
|
388
483
|
|
|
389
484
|
function reset() {
|
|
390
485
|
generationSeqRef.current += 1;
|
|
486
|
+
clearToolTaskRecovery(RECOVERY_SLUG);
|
|
391
487
|
if (logoInputRef.current) logoInputRef.current.value = "";
|
|
392
488
|
if (screenshotInputRef.current) screenshotInputRef.current.value = "";
|
|
393
489
|
setForm(defaultForm);
|
|
394
490
|
setResult({ phase: "idle" });
|
|
395
491
|
setFieldErrors({});
|
|
396
492
|
setFormError(null);
|
|
493
|
+
setDownloadError(null);
|
|
494
|
+
setDownloading(false);
|
|
397
495
|
setAdvancedOpen(false);
|
|
398
496
|
}
|
|
399
497
|
|
|
@@ -599,14 +697,16 @@ export function ProductPosterGenerator() {
|
|
|
599
697
|
body={result.data.summary ?? t("readyHint")}
|
|
600
698
|
actions={
|
|
601
699
|
<ToolResultActions
|
|
602
|
-
downloadLabel={t("downloadPng")}
|
|
700
|
+
downloadLabel={downloading ? t("loadingSteps.finish") : t("downloadPng")}
|
|
603
701
|
resetLabel={t("reset")}
|
|
604
|
-
onDownload={downloadPosterPng}
|
|
702
|
+
onDownload={() => void downloadPosterPng()}
|
|
605
703
|
onReset={reset}
|
|
606
704
|
/>
|
|
607
705
|
}
|
|
608
706
|
/>
|
|
609
707
|
|
|
708
|
+
{downloadError ? <ToolErrorBanner>{downloadError}</ToolErrorBanner> : null}
|
|
709
|
+
|
|
610
710
|
<figure className="flex min-h-[28rem] items-center justify-center overflow-hidden rounded-[1.25rem] border border-[#E4E9F1] bg-white p-3">
|
|
611
711
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
612
712
|
<img
|