@tomako/tools-runtime 0.1.5 → 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.
Files changed (43) hide show
  1. package/package.json +2 -1
  2. package/src/components/tools/engine/package-widget-runtime.tsx +5 -1
  3. package/src/components/tools/engine/task-recovery.test.ts +65 -0
  4. package/src/components/tools/engine/task-recovery.ts +109 -0
  5. package/src/components/tools/engine/use-legacy-llm-task.ts +16 -3
  6. package/src/components/tools/engine/use-tool-task.ts +77 -53
  7. package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx +44 -6
  8. package/src/features/tools/app-store-screenshot-generator/widget/generation.test.ts +126 -0
  9. package/src/features/tools/app-store-screenshot-generator/widget/generation.ts +52 -14
  10. package/src/features/tools/cold-start-channel-selector/widget/cold-start-channel-selector.tsx +45 -16
  11. package/src/features/tools/competitor-analysis/competitor-analysis.container.tsx +26 -20
  12. package/src/features/tools/competitor-analysis/widget/competitor-analysis.tsx +47 -16
  13. package/src/features/tools/disclaimer-generator/disclaimer-generator.package.ts +3 -3
  14. package/src/features/tools/email-template-generator/widget/email-template-generator.tsx +37 -4
  15. package/src/features/tools/package/to-tool-module.ts +15 -0
  16. package/src/features/tools/product-name-generator/widget/product-name-generator.tsx +60 -27
  17. package/src/features/tools/product-poster-generator/widget/constants.ts +3 -1
  18. package/src/features/tools/product-poster-generator/widget/helpers.test.ts +51 -0
  19. package/src/features/tools/product-poster-generator/widget/helpers.ts +27 -0
  20. package/src/features/tools/product-poster-generator/widget/product-poster-generator.tsx +112 -12
  21. package/src/features/tools/slogan-generator/slogan-generator.package.ts +28 -28
  22. package/src/i18n/messages/en/tools/app-icon-resizer.ts +1 -1
  23. package/src/i18n/messages/en/tools/app-store-screenshot-generator.ts +2 -2
  24. package/src/i18n/messages/en/tools/cold-start-channel-selector.ts +4 -4
  25. package/src/i18n/messages/en/tools/competitor-analysis.ts +9 -8
  26. package/src/i18n/messages/en/tools/email-template-generator.ts +10 -10
  27. package/src/i18n/messages/en/tools/product-poster-generator.ts +1 -0
  28. package/src/i18n/messages/zh/tools/app-store-screenshot-generator.ts +3 -3
  29. package/src/i18n/messages/zh/tools/cold-start-channel-selector.ts +2 -2
  30. package/src/i18n/messages/zh/tools/competitor-analysis.ts +10 -9
  31. package/src/i18n/messages/zh/tools/email-template-generator.ts +11 -11
  32. package/src/i18n/messages/zh/tools/product-poster-generator.ts +1 -0
  33. package/src/i18n/messages/zh-tw/tools/app-store-screenshot-generator.ts +44 -44
  34. package/src/i18n/messages/zh-tw/tools/cold-start-channel-selector.ts +105 -105
  35. package/src/i18n/messages/zh-tw/tools/competitor-analysis.ts +41 -40
  36. package/src/i18n/messages/zh-tw/tools/email-template-generator.ts +111 -111
  37. package/src/i18n/messages/zh-tw/tools/product-name-generator.ts +92 -92
  38. package/src/i18n/messages/zh-tw/tools/product-poster-generator.ts +74 -73
  39. package/src/i18n/messages/zh-tw/tools/twitter-gif-downloader.ts +47 -47
  40. package/src/lib/tools/cold-start-channel-selector-schema.test.ts +39 -0
  41. package/src/lib/tools/cold-start-channel-selector-schema.ts +8 -4
  42. package/src/lib/tools/competitor-analysis-schema.test.ts +69 -0
  43. package/src/lib/tools/competitor-analysis-schema.ts +53 -5
@@ -33,16 +33,30 @@ export async function generateStoreAssets(
33
33
  locale: AppLocale,
34
34
  onProgress: (progress: GeneratedStoreAssetsResult) => void,
35
35
  onPlanning: () => void,
36
+ options: {
37
+ taskId?: string;
38
+ startedAtMs?: number;
39
+ onTaskSubmitted?: (taskId: string, startedAtMs: number) => void;
40
+ } = {},
36
41
  ): Promise<GeneratedStoreAssetsResult> {
37
- const submitted = await submitAppStoreScreenshotSet(input, locale);
42
+ const submitted = options.taskId
43
+ ? { taskId: options.taskId }
44
+ : await submitAppStoreScreenshotSet(input, locale);
45
+ const startedAtMs = options.startedAtMs ?? Date.now();
46
+ if (!options.taskId) options.onTaskSubmitted?.(submitted.taskId, startedAtMs);
38
47
  onPlanning();
39
- const agentResult = await waitForAgentResult(submitted.taskId, (partialResult) => {
40
- try {
41
- onProgress(buildGeneratedStoreAssetsResult(partialResult, { allowMissingImageUrl: false }));
42
- } catch {
43
- // Ignore non-renderable intermediate snapshots and keep waiting for a usable result.
44
- }
45
- });
48
+ const agentResult = await waitForAgentResult(
49
+ submitted.taskId,
50
+ (partialResult) => {
51
+ try {
52
+ onProgress(buildGeneratedStoreAssetsResult(partialResult, { allowMissingImageUrl: false }));
53
+ } catch {
54
+ // Ignore non-renderable intermediate snapshots and keep waiting for a usable result.
55
+ }
56
+ },
57
+ expectedAssetCount(input),
58
+ { startedAtMs },
59
+ );
46
60
  return resolveGeneratedStoreAssetsResult(agentResult, input, onProgress);
47
61
  }
48
62
 
@@ -247,8 +261,17 @@ export function buildGeneratedStoreAssetsResult(
247
261
  export async function waitForAgentResult(
248
262
  taskId: string,
249
263
  onProgress: (result: AppStoreScreenshotAgentResult) => void,
264
+ expectedCount: number,
265
+ options: {
266
+ pollIntervalMs?: number;
267
+ timeoutMs?: number;
268
+ startedAtMs?: number;
269
+ } = {},
250
270
  ) {
251
- const deadline = Date.now() + AGENT_RESULT_TIMEOUT_MS;
271
+ const deadline =
272
+ (options.startedAtMs ?? Date.now()) +
273
+ (options.timeoutMs ?? AGENT_RESULT_TIMEOUT_MS);
274
+ const pollIntervalMs = options.pollIntervalMs ?? AGENT_RESULT_POLL_INTERVAL_MS;
252
275
  let lastResult: AppStoreScreenshotAgentResult | null = null;
253
276
  let lastSignature = "";
254
277
 
@@ -261,7 +284,13 @@ export async function waitForAgentResult(
261
284
  lastSignature = signature;
262
285
  onProgress(task.data);
263
286
  }
264
- if (task.data.reportStatus === "final" || hasResolvableAgentAssets(task.data)) {
287
+ // submit-only can finish the LLM task after writing the first partial batch while
288
+ // its background follower continues to append screenshot tasks. Do not treat the
289
+ // task's SUCCEEDED status or one resolvable asset as the completed image set.
290
+ if (
291
+ task.data.reportStatus === "final" ||
292
+ hasExpectedAgentAssetCount(task.data, expectedCount)
293
+ ) {
265
294
  return task.data;
266
295
  }
267
296
  }
@@ -269,15 +298,24 @@ export async function waitForAgentResult(
269
298
  if (lastResult) return lastResult;
270
299
  throw new AppStoreAgentGenerationError(task.errorCode, task.error);
271
300
  }
272
- await wait(AGENT_RESULT_POLL_INTERVAL_MS);
301
+ await wait(pollIntervalMs);
273
302
  }
274
303
 
275
304
  if (lastResult) return lastResult;
276
305
  throw new AppStoreAgentGenerationError("task_failed", "Agent result timed out.");
277
306
  }
278
307
 
279
- export function hasResolvableAgentAssets(result: AppStoreScreenshotAgentResult) {
280
- return result.assets.some((asset) => Boolean(asset.imageUrl || asset.imageTaskId));
308
+ export function hasExpectedAgentAssetCount(
309
+ result: AppStoreScreenshotAgentResult,
310
+ expectedCount: number,
311
+ ) {
312
+ const settledAssetIds = new Set([
313
+ ...result.assets
314
+ .filter((asset) => Boolean(asset.imageUrl || asset.imageTaskId || asset.errorMessage))
315
+ .map((asset) => asset.id),
316
+ ...result.failures.map((failure) => failure.id),
317
+ ]);
318
+ return settledAssetIds.size >= clampAssetCount(expectedCount);
281
319
  }
282
320
 
283
321
  export function progressSignature(result: AppStoreScreenshotAgentResult) {
@@ -459,7 +497,7 @@ export function blobToImage(blob: Blob): Promise<HTMLImageElement> {
459
497
  }
460
498
 
461
499
  export function wait(ms: number): Promise<void> {
462
- return new Promise((resolve) => window.setTimeout(resolve, ms));
500
+ return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
463
501
  }
464
502
 
465
503
  export function formatGenerationError(
@@ -28,6 +28,11 @@ import {
28
28
  } from "../../../../host/client";
29
29
 
30
30
  import { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
31
+ import {
32
+ clearToolTaskRecovery,
33
+ persistToolTaskRecovery,
34
+ readToolTaskRecovery,
35
+ } from "../../../../components/tools/engine/task-recovery";
31
36
  import { GRACE_WINDOW_MS, initialInput } from "./constants";
32
37
  import { SelectField } from "./form-controls";
33
38
  import { EmptyPreview, RunningPanel } from "./preview-panels";
@@ -38,6 +43,8 @@ type FormFieldErrors = Partial<
38
43
  Record<"productUrl" | "productDescription", string>
39
44
  >;
40
45
 
46
+ const RECOVERY_SLUG = "cold-start-channel-selector";
47
+
41
48
  export function ColdStartChannelSelector() {
42
49
  const t = useTranslations("toolsPages.coldStartChannelSelector.widget");
43
50
  const locale = useLocale() as AppLocale;
@@ -74,6 +81,22 @@ export function ColdStartChannelSelector() {
74
81
  };
75
82
  }, [taskWatch]);
76
83
 
84
+ useEffect(() => {
85
+ const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
86
+ parseInput: (value) => {
87
+ const parsed = coldStartChannelSelectorInputSchema.safeParse(value);
88
+ return parsed.success ? parsed.data : null;
89
+ },
90
+ });
91
+ if (!recovered) return;
92
+ setInput(recovered.input);
93
+ cancelledRef.current = false;
94
+ settledRef.current = false;
95
+ watchAnalysis(recovered.taskId, recovered.startedAtMs);
96
+ // Restore only once for this mounted tool route.
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [taskWatch]);
99
+
77
100
  function updateInput<K extends keyof ColdStartChannelSelectorInput>(
78
101
  key: K,
79
102
  value: ColdStartChannelSelectorInput[K],
@@ -117,6 +140,24 @@ export function ColdStartChannelSelector() {
117
140
  return { settled: false as const };
118
141
  }
119
142
 
143
+ function watchAnalysis(taskId: string, startedAtMs?: number) {
144
+ setState({ phase: "running", status: startedAtMs ? "RESTORING" : "PENDING" });
145
+ taskWatch.start(
146
+ taskId,
147
+ pollOnce,
148
+ {
149
+ onStatus: (status) => {
150
+ if (!cancelledRef.current && !settledRef.current) {
151
+ setState({ phase: "running", status });
152
+ }
153
+ },
154
+ onSuccess: (data) => finishDone(data),
155
+ onError: (category) => finishError(category === "timeout" ? "timeout" : category),
156
+ },
157
+ { graceWindowMs: GRACE_WINDOW_MS, startedAtMs },
158
+ );
159
+ }
160
+
120
161
  async function startAnalysis() {
121
162
  const parsed = coldStartChannelSelectorInputSchema.safeParse(input);
122
163
  if (!parsed.success) {
@@ -159,28 +200,16 @@ export function ColdStartChannelSelector() {
159
200
  }
160
201
 
161
202
  if (cancelledRef.current) return;
162
- setState({ phase: "running", status: "PENDING" });
163
-
164
- taskWatch.start(
165
- taskId,
166
- pollOnce,
167
- {
168
- onStatus: (status) => {
169
- if (!cancelledRef.current && !settledRef.current) {
170
- setState({ phase: "running", status });
171
- }
172
- },
173
- onSuccess: (data) => finishDone(data),
174
- onError: (category) => finishError(category === "timeout" ? "timeout" : category),
175
- },
176
- { graceWindowMs: GRACE_WINDOW_MS },
177
- );
203
+ const startedAtMs = Date.now();
204
+ persistToolTaskRecovery(RECOVERY_SLUG, taskId, parsed.data, startedAtMs);
205
+ watchAnalysis(taskId, startedAtMs);
178
206
  }
179
207
 
180
208
  function resetResult() {
181
209
  cancelledRef.current = false;
182
210
  settledRef.current = false;
183
211
  cleanup();
212
+ clearToolTaskRecovery(RECOVERY_SLUG);
184
213
  setState({ phase: "idle" });
185
214
  }
186
215
 
@@ -1,4 +1,4 @@
1
- import { ImageOff } from "lucide-react";
1
+ import Image from "next/image";
2
2
  import { createToolGuideTranslator } from "../shared/tool-message-translator";
3
3
 
4
4
  import { CompetitorAnalysis } from "./widget";
@@ -17,30 +17,25 @@ import { ToolWorkspaceSection } from "../shared/tool-guide-section";
17
17
  import { ToolPageShell } from "../shared/tool-page-shell";
18
18
  import type { ToolPageProps } from "../types";
19
19
 
20
- /**
21
- * Gray-white image placeholder. The agent has no image-generation capability,
22
- * so first-pass bitmap assets are intentionally deferred (see release notes /
23
- * tool brief). Swap each placeholder for a real `public/tools/competitor-analysis-*.webp`
24
- * when assets are produced.
25
- */
26
- function ImagePlaceholder({
20
+ function CompetitorVisual({
21
+ src,
27
22
  label,
28
23
  className = "",
29
24
  }: {
25
+ src: string;
30
26
  label: string;
31
27
  className?: string;
32
28
  }) {
33
29
  return (
34
- <div
35
- role="img"
36
- aria-label={label}
37
- className={`flex aspect-[16/10] w-full items-center justify-center rounded-[1.5rem] border border-dashed border-[#D7DCE3] bg-[#F4F5F7] text-[#A8B0BA] ${className}`}
38
- >
39
- <span className="flex flex-col items-center gap-2 text-xs font-medium">
40
- <ImageOff className="size-6" aria-hidden />
41
- {label}
42
- </span>
43
- </div>
30
+ <figure className={`relative aspect-[16/10] w-full overflow-hidden rounded-[1.5rem] border border-[#D7DCE3] bg-[#F4F5F7] ${className}`}>
31
+ <Image
32
+ src={src}
33
+ alt={label}
34
+ fill
35
+ sizes="(min-width: 1024px) 560px, 100vw"
36
+ className="object-cover"
37
+ />
38
+ </figure>
44
39
  );
45
40
  }
46
41
 
@@ -80,12 +75,18 @@ export async function CompetitorAnalysisContainer({ locale, spec }: ToolPageProp
80
75
  <p className="text-sm leading-6 text-[#777777]">{t("runtimeNote")}</p>
81
76
  </div>
82
77
  </div>
83
- <ImagePlaceholder label={t("visualAlt.howToUse")} />
78
+ <CompetitorVisual
79
+ src="/tools/competitor-radar-how-it-works.webp"
80
+ label={t("visualAlt.howToUse")}
81
+ />
84
82
  </section>
85
83
 
86
84
  {/* Result guidance — visual-led split */}
87
85
  <section className="grid gap-8 border-t border-[#E8E4DB] py-12 md:grid-cols-[1.08fr_0.92fr] md:items-center md:py-16">
88
- <ImagePlaceholder label={t("visualAlt.result")} />
86
+ <CompetitorVisual
87
+ src="/tools/competitor-radar-trust-boundary.webp"
88
+ label={t("visualAlt.result")}
89
+ />
89
90
  <div>
90
91
  <ToolSectionEyebrow tone="blue">{t("resultKicker")}</ToolSectionEyebrow>
91
92
  <h2 className="mt-3 text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
@@ -109,6 +110,11 @@ export async function CompetitorAnalysisContainer({ locale, spec }: ToolPageProp
109
110
  <p className="max-w-md text-sm leading-6 text-[#777777]">{t("useCasesIntro")}</p>
110
111
  </div>
111
112
  <ToolNumberedUseCaseGrid items={useCases} />
113
+ <CompetitorVisual
114
+ src="/tools/competitor-radar-use-cases.webp"
115
+ label={t("visualAlt.useCases")}
116
+ className="mt-8"
117
+ />
112
118
  </section>
113
119
 
114
120
  {/* FAQ + boundary */}
@@ -22,11 +22,18 @@ import {
22
22
  } from "../../../../host/client";
23
23
 
24
24
  import { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
25
+ import {
26
+ clearToolTaskRecovery,
27
+ persistToolTaskRecovery,
28
+ readToolTaskRecovery,
29
+ } from "../../../../components/tools/engine/task-recovery";
25
30
  import { GRACE_WINDOW_MS } from "./constants";
26
31
  import { EmptyPanel, ErrorPanel, RunningPanel } from "./preview-panels";
27
32
  import { Dossier } from "./result-shell";
28
33
  import type { ErrorCategory, WidgetState } from "./types";
29
34
 
35
+ const RECOVERY_SLUG = "competitor-analysis";
36
+
30
37
  export function CompetitorAnalysis() {
31
38
  const t = useTranslations("toolsPages.competitorAnalysis.widget");
32
39
  const locale = useLocale() as AppLocale;
@@ -53,6 +60,24 @@ export function CompetitorAnalysis() {
53
60
  };
54
61
  }, [taskWatch]);
55
62
 
63
+ useEffect(() => {
64
+ const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
65
+ parseInput: (value) => {
66
+ const parsed = competitorAnalysisInputSchema.safeParse(value);
67
+ return parsed.success ? parsed.data : null;
68
+ },
69
+ });
70
+ if (!recovered) return;
71
+ setUrl(recovered.input.competitorUrl);
72
+ setName(recovered.input.competitorName ?? "");
73
+ setNotes(recovered.input.notes ?? "");
74
+ cancelledRef.current = false;
75
+ settledRef.current = false;
76
+ watchAnalysis(recovered.taskId, recovered.startedAtMs);
77
+ // Restore only once for this mounted tool route.
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [taskWatch]);
80
+
56
81
  useEffect(() => {
57
82
  if (!activityBrief || state.phase !== "done") return;
58
83
  publishActivityArtifact(
@@ -93,6 +118,24 @@ export function CompetitorAnalysis() {
93
118
  return { settled: false as const };
94
119
  }
95
120
 
121
+ function watchAnalysis(taskId: string, startedAtMs?: number) {
122
+ setState({ phase: "running", status: startedAtMs ? "RESTORING" : "PENDING" });
123
+ taskWatch.start(
124
+ taskId,
125
+ pollOnce,
126
+ {
127
+ onStatus: (status) => {
128
+ if (!cancelledRef.current && !settledRef.current) {
129
+ setState({ phase: "running", status });
130
+ }
131
+ },
132
+ onSuccess: (data) => finishDone(data, taskId),
133
+ onError: (category) => finishError(category === "timeout" ? "timeout" : category),
134
+ },
135
+ { graceWindowMs: GRACE_WINDOW_MS, startedAtMs },
136
+ );
137
+ }
138
+
96
139
  async function startAnalysis() {
97
140
  const parsed = competitorAnalysisInputSchema.safeParse({
98
141
  competitorUrl: url,
@@ -119,28 +162,16 @@ export function CompetitorAnalysis() {
119
162
  return;
120
163
  }
121
164
  if (cancelledRef.current) return;
122
- setState({ phase: "running", status: "PENDING" });
123
-
124
- taskWatch.start(
125
- taskId,
126
- pollOnce,
127
- {
128
- onStatus: (status) => {
129
- if (!cancelledRef.current && !settledRef.current) {
130
- setState({ phase: "running", status });
131
- }
132
- },
133
- onSuccess: (data) => finishDone(data, taskId),
134
- onError: (category) => finishError(category === "timeout" ? "timeout" : category),
135
- },
136
- { graceWindowMs: GRACE_WINDOW_MS },
137
- );
165
+ const startedAtMs = Date.now();
166
+ persistToolTaskRecovery(RECOVERY_SLUG, taskId, parsed.data, startedAtMs);
167
+ watchAnalysis(taskId, startedAtMs);
138
168
  }
139
169
 
140
170
  function reset() {
141
171
  cancelledRef.current = false;
142
172
  settledRef.current = false;
143
173
  cleanup();
174
+ clearToolTaskRecovery(RECOVERY_SLUG);
144
175
  setState({ phase: "idle" });
145
176
  }
146
177
 
@@ -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
- "Create a tailored website, affiliate, review, or professional-information disclaimer draft with placement guidance, missing facts, risk flags, and a human review checklist.",
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("Create a disclaimer you can review", "生成一份便于复核的免责声明", "產生一份便於複核的免責聲明"),
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(taskId: string, previous?: EmailTemplateResult) {
80
- setState({ phase: "running", status: "PENDING", taskId, previous });
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
- startWatch(submitted.taskId);
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
- activeScreenTaskIdRef.current = taskId;
341
- setScreenState({ phase: "loading", status, taskId });
342
- screenTaskWatch.start(
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
- export const MAX_TASK_POLL_ATTEMPTS = 120;
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;