@tomako/tools-runtime 0.1.4 → 0.1.6
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-icon-resizer/app-icon-resizer.spec.ts +1 -1
- package/src/features/tools/app-store-screenshot-generator/app-store-screenshot-generator.container.tsx +6 -8
- package/src/features/tools/app-store-screenshot-generator/app-store-screenshot-generator.spec.ts +1 -1
- package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx +44 -6
- 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/cold-start-channel-selector/cold-start-channel-selector.spec.ts +1 -1
- 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 +31 -26
- package/src/features/tools/competitor-analysis/competitor-analysis.spec.ts +1 -1
- package/src/features/tools/competitor-analysis/widget/competitor-analysis.tsx +47 -16
- package/src/features/tools/disclaimer-generator/disclaimer-generator.package.ts +4 -4
- package/src/features/tools/email-template-generator/email-template-generator.container.tsx +45 -173
- package/src/features/tools/email-template-generator/email-template-generator.spec.ts +1 -1
- 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/product-name-generator.spec.ts +1 -1
- package/src/features/tools/product-name-generator/widget/product-name-generator.tsx +60 -27
- package/src/features/tools/product-poster-generator/product-poster-generator.spec.ts +1 -1
- 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 +101 -29
- package/src/features/tools/twitter-gif-downloader/twitter-gif-downloader.container.tsx +2 -3
- package/src/features/tools/twitter-gif-downloader/twitter-gif-downloader.spec.ts +1 -1
- package/src/i18n/messages/en/tools/app-icon-resizer.ts +1 -1
- package/src/i18n/messages/en/tools/app-store-screenshot-generator.ts +3 -3
- package/src/i18n/messages/en/tools/cold-start-channel-selector.ts +4 -4
- package/src/i18n/messages/en/tools/competitor-analysis.ts +14 -10
- package/src/i18n/messages/en/tools/email-template-generator.ts +13 -13
- package/src/i18n/messages/en/tools/product-poster-generator.ts +3 -2
- package/src/i18n/messages/en/tools/twitter-gif-downloader.ts +1 -1
- package/src/i18n/messages/zh/tools/app-store-screenshot-generator.ts +4 -4
- package/src/i18n/messages/zh/tools/cold-start-channel-selector.ts +2 -2
- package/src/i18n/messages/zh/tools/competitor-analysis.ts +14 -10
- package/src/i18n/messages/zh/tools/email-template-generator.ts +12 -12
- package/src/i18n/messages/zh/tools/product-poster-generator.ts +2 -1
- package/src/i18n/messages/zh/tools/twitter-gif-downloader.ts +1 -1
- 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 +44 -40
- package/src/i18n/messages/zh-tw/tools/email-template-generator.ts +112 -112
- 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 +48 -48
- 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
- package/src/lib/tools/ops-catalog.ts +10 -1
|
@@ -3,216 +3,88 @@ import { createToolGuideTranslator } from "../shared/tool-message-translator";
|
|
|
3
3
|
import { EmailTemplateGenerator } from "./widget";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
ToolBoundarySection,
|
|
7
|
+
ToolFaqSection,
|
|
8
|
+
ToolSection,
|
|
9
|
+
ToolStandardsSection,
|
|
10
|
+
ToolTextRows,
|
|
11
|
+
ToolUseCasesSection,
|
|
12
|
+
ToolValueSection,
|
|
13
|
+
type ToolBoundaryGroup,
|
|
14
|
+
type ToolFaqItem,
|
|
15
|
+
type ToolTextCard,
|
|
8
16
|
} from "../shared/tool-content-sections";
|
|
9
17
|
import { ToolCta } from "../shared/tool-cta";
|
|
10
18
|
import { ToolImageVisual, ToolWorkspaceSection } from "../shared/tool-guide-section";
|
|
11
19
|
import { ToolPageShell } from "../shared/tool-page-shell";
|
|
12
20
|
import type { ToolPageProps } from "../types";
|
|
13
21
|
|
|
14
|
-
type TextCard = {
|
|
15
|
-
title: string;
|
|
16
|
-
body: string;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
type BoundaryGroup = {
|
|
20
|
-
title: string;
|
|
21
|
-
items: readonly string[];
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
type FaqItem = {
|
|
25
|
-
question: string;
|
|
26
|
-
answer: string;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function OutputSection({
|
|
30
|
-
heading,
|
|
31
|
-
intro,
|
|
32
|
-
items,
|
|
33
|
-
visualAlt,
|
|
34
|
-
}: {
|
|
35
|
-
heading: string;
|
|
36
|
-
intro: string;
|
|
37
|
-
items: readonly TextCard[];
|
|
38
|
-
visualAlt: string;
|
|
39
|
-
}) {
|
|
40
|
-
return (
|
|
41
|
-
<section className="border-t border-[#E1DDD3] py-14 md:py-16">
|
|
42
|
-
<div className="grid gap-8 md:grid-cols-[0.92fr_1.08fr] md:items-center">
|
|
43
|
-
<div>
|
|
44
|
-
<h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
|
|
45
|
-
{heading}
|
|
46
|
-
</h2>
|
|
47
|
-
<p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
|
|
48
|
-
</div>
|
|
49
|
-
<ToolImageVisual src="/tools/email-template-generator-value.webp" alt={visualAlt} wide />
|
|
50
|
-
</div>
|
|
51
|
-
<div className="mt-8">
|
|
52
|
-
<ToolCardGrid items={items} />
|
|
53
|
-
</div>
|
|
54
|
-
</section>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function StandardsSection({
|
|
59
|
-
heading,
|
|
60
|
-
intro,
|
|
61
|
-
items,
|
|
62
|
-
}: {
|
|
63
|
-
heading: string;
|
|
64
|
-
intro: string;
|
|
65
|
-
items: readonly TextCard[];
|
|
66
|
-
}) {
|
|
67
|
-
return (
|
|
68
|
-
<section className="border-t border-[#E1DDD3] py-14 md:py-16">
|
|
69
|
-
<div className="max-w-3xl">
|
|
70
|
-
<h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
|
|
71
|
-
{heading}
|
|
72
|
-
</h2>
|
|
73
|
-
<p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
|
|
74
|
-
</div>
|
|
75
|
-
<div className="mt-7">
|
|
76
|
-
<ToolCardGrid items={items} />
|
|
77
|
-
</div>
|
|
78
|
-
</section>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function BoundarySection({
|
|
83
|
-
heading,
|
|
84
|
-
intro,
|
|
85
|
-
groups,
|
|
86
|
-
visualAlt,
|
|
87
|
-
}: {
|
|
88
|
-
heading: string;
|
|
89
|
-
intro: string;
|
|
90
|
-
groups: readonly BoundaryGroup[];
|
|
91
|
-
visualAlt: string;
|
|
92
|
-
}) {
|
|
93
|
-
return (
|
|
94
|
-
<section className="grid gap-8 border-t border-[#E1DDD3] py-14 md:grid-cols-[0.98fr_1.02fr] md:items-center md:py-16">
|
|
95
|
-
<ToolImageVisual src="/tools/email-template-generator-support.webp" alt={visualAlt} />
|
|
96
|
-
<div>
|
|
97
|
-
<h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
|
|
98
|
-
{heading}
|
|
99
|
-
</h2>
|
|
100
|
-
<p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
|
|
101
|
-
<div className="mt-6 grid gap-3">
|
|
102
|
-
{groups.map((group) => (
|
|
103
|
-
<article
|
|
104
|
-
key={group.title}
|
|
105
|
-
className="rounded-[1.25rem] border border-[#D8D4CA] bg-white p-5 shadow-[0_12px_28px_rgba(17,17,17,0.05)]"
|
|
106
|
-
>
|
|
107
|
-
<h3 className="text-base font-semibold leading-tight text-[#111111]">
|
|
108
|
-
{group.title}
|
|
109
|
-
</h3>
|
|
110
|
-
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm leading-6 text-[#555555]">
|
|
111
|
-
{group.items.map((item) => (
|
|
112
|
-
<li key={item}>{item}</li>
|
|
113
|
-
))}
|
|
114
|
-
</ul>
|
|
115
|
-
</article>
|
|
116
|
-
))}
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
</section>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function GuidanceSection({
|
|
124
|
-
heading,
|
|
125
|
-
intro,
|
|
126
|
-
items,
|
|
127
|
-
visualAlt,
|
|
128
|
-
}: {
|
|
129
|
-
heading: string;
|
|
130
|
-
intro: string;
|
|
131
|
-
items: readonly string[];
|
|
132
|
-
visualAlt: string;
|
|
133
|
-
}) {
|
|
134
|
-
return (
|
|
135
|
-
<section className="grid gap-8 border-t border-[#E1DDD3] py-14 md:grid-cols-[1fr_1fr] md:items-center md:py-16">
|
|
136
|
-
<div>
|
|
137
|
-
<h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
|
|
138
|
-
{heading}
|
|
139
|
-
</h2>
|
|
140
|
-
<p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
|
|
141
|
-
<div className="mt-6 space-y-4">
|
|
142
|
-
{items.map((item) => (
|
|
143
|
-
<p key={item} className="text-base leading-7 text-[#3D3D3D]">
|
|
144
|
-
{item}
|
|
145
|
-
</p>
|
|
146
|
-
))}
|
|
147
|
-
</div>
|
|
148
|
-
</div>
|
|
149
|
-
<ToolImageVisual src="/tools/email-template-generator-export.webp" alt={visualAlt} />
|
|
150
|
-
</section>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
22
|
export async function EmailTemplateGeneratorContainer({ locale, spec }: ToolPageProps) {
|
|
155
23
|
const t = await createToolGuideTranslator(locale, "toolsPages.emailTemplateGenerator.guide");
|
|
156
|
-
const outputItems = t.raw("outputItems") as readonly
|
|
157
|
-
const standardsItems = t.raw("standardsItems") as readonly
|
|
158
|
-
const boundaryGroups = t.raw("boundaryGroups") as readonly
|
|
24
|
+
const outputItems = t.raw("outputItems") as readonly ToolTextCard[];
|
|
25
|
+
const standardsItems = t.raw("standardsItems") as readonly ToolTextCard[];
|
|
26
|
+
const boundaryGroups = t.raw("boundaryGroups") as readonly ToolBoundaryGroup[];
|
|
159
27
|
const guidance = t.raw("guidanceItems") as readonly string[];
|
|
160
|
-
const useCases = t.raw("useCaseItems") as readonly
|
|
161
|
-
const faqItems = t.raw("faqItems") as readonly
|
|
28
|
+
const useCases = t.raw("useCaseItems") as readonly ToolTextCard[];
|
|
29
|
+
const faqItems = t.raw("faqItems") as readonly ToolFaqItem[];
|
|
162
30
|
|
|
163
31
|
return (
|
|
164
|
-
<ToolPageShell
|
|
165
|
-
locale={locale}
|
|
166
|
-
spec={spec}
|
|
167
|
-
heroVariant="immersive"
|
|
168
|
-
heroBackgroundImage="/tools/email-template-generator-hero.webp"
|
|
169
|
-
heroBackgroundAlt={t("heroImageAlt")}
|
|
170
|
-
>
|
|
32
|
+
<ToolPageShell locale={locale} spec={spec} showDefaultHeader={false}>
|
|
171
33
|
<ToolWorkspaceSection
|
|
172
34
|
heading={t("workspaceHeading")}
|
|
173
35
|
hint={t("workspaceHint")}
|
|
36
|
+
headingLevel="h1"
|
|
174
37
|
>
|
|
175
38
|
<EmailTemplateGenerator />
|
|
176
39
|
</ToolWorkspaceSection>
|
|
177
40
|
|
|
178
|
-
<
|
|
41
|
+
<ToolValueSection
|
|
179
42
|
heading={t("outputs")}
|
|
180
43
|
intro={t("outputsIntro")}
|
|
181
44
|
items={outputItems}
|
|
182
|
-
|
|
45
|
+
visual={
|
|
46
|
+
<ToolImageVisual
|
|
47
|
+
src="/tools/email-template-generator-value.webp"
|
|
48
|
+
alt={t("valueVisualAlt")}
|
|
49
|
+
wide
|
|
50
|
+
/>
|
|
51
|
+
}
|
|
183
52
|
/>
|
|
184
53
|
|
|
185
|
-
<
|
|
54
|
+
<ToolStandardsSection
|
|
186
55
|
heading={t("standards")}
|
|
187
56
|
intro={t("standardsIntro")}
|
|
188
57
|
items={standardsItems}
|
|
189
58
|
/>
|
|
190
59
|
|
|
191
|
-
<
|
|
60
|
+
<ToolBoundarySection
|
|
192
61
|
heading={t("boundary")}
|
|
193
62
|
intro={t("boundaryIntro")}
|
|
194
63
|
groups={boundaryGroups}
|
|
195
|
-
|
|
64
|
+
reverse
|
|
65
|
+
visual={
|
|
66
|
+
<ToolImageVisual
|
|
67
|
+
src="/tools/email-template-generator-support.webp"
|
|
68
|
+
alt={t("supportVisualAlt")}
|
|
69
|
+
/>
|
|
70
|
+
}
|
|
196
71
|
/>
|
|
197
72
|
|
|
198
|
-
<
|
|
73
|
+
<ToolSection
|
|
199
74
|
heading={t("guidance")}
|
|
200
75
|
intro={t("guidanceIntro")}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
<ToolCardGrid items={useCases} />
|
|
211
|
-
</div>
|
|
212
|
-
</section>
|
|
76
|
+
visual={
|
|
77
|
+
<ToolImageVisual
|
|
78
|
+
src="/tools/email-template-generator-export.webp"
|
|
79
|
+
alt={t("exportVisualAlt")}
|
|
80
|
+
/>
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
<ToolTextRows items={guidance} />
|
|
84
|
+
</ToolSection>
|
|
213
85
|
|
|
86
|
+
<ToolUseCasesSection heading={t("useCases")} items={useCases} />
|
|
214
87
|
<ToolFaqSection heading={t("faq")} items={faqItems} />
|
|
215
|
-
|
|
216
88
|
<ToolCta
|
|
217
89
|
heading={t("ctaTitle")}
|
|
218
90
|
hint={t("ctaHint")}
|
|
@@ -5,7 +5,7 @@ export const emailTemplateGeneratorSpec: ToolSpec = {
|
|
|
5
5
|
toolType: "generator",
|
|
6
6
|
seo: {
|
|
7
7
|
"publishedAt": "2026-06-29",
|
|
8
|
-
"updatedAt": "2026-
|
|
8
|
+
"updatedAt": "2026-08-30",
|
|
9
9
|
"readingTime": "6 min",
|
|
10
10
|
"featured": false,
|
|
11
11
|
"ogImage": "/tools/email-template-generator-hero.webp"
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ export const productNameGeneratorSpec: ToolSpec = {
|
|
|
5
5
|
toolType: "generator",
|
|
6
6
|
seo: {
|
|
7
7
|
"publishedAt": "2026-06-25",
|
|
8
|
-
"updatedAt": "2026-08-
|
|
8
|
+
"updatedAt": "2026-08-30",
|
|
9
9
|
"readingTime": "6 min",
|
|
10
10
|
"featured": true,
|
|
11
11
|
"ogImage": "/tools/product-name-generator-section-primary-8bit-v2.webp",
|
|
@@ -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({});
|
|
@@ -6,7 +6,7 @@ export const productPosterGeneratorSpec: ToolSpec = {
|
|
|
6
6
|
riskLevel: "medium",
|
|
7
7
|
seo: {
|
|
8
8
|
"publishedAt": "2026-06-29",
|
|
9
|
-
"updatedAt": "2026-
|
|
9
|
+
"updatedAt": "2026-08-30",
|
|
10
10
|
"readingTime": "6 min",
|
|
11
11
|
"featured": false,
|
|
12
12
|
"ogImage": "/tools/product-poster-generator-section-primary-8bit-v2.webp"
|
|
@@ -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 {
|