@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
|
@@ -54,8 +54,9 @@ export function sortGeneratedAssets(assets: AppStoreScreenshotAsset[]) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function
|
|
58
|
-
|
|
57
|
+
export function gridClassForAssetFormat(format: AssetFormat, isSoleResult = false) {
|
|
58
|
+
if (isSoleResult) return "mx-auto max-w-[340px]";
|
|
59
|
+
return format === "featureGraphic" ? "sm:col-span-2 xl:col-span-3" : "";
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export function dimensionsForAssetFormat(format: AssetFormat) {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { registerToolsRuntimeHostClient } from "../../../../host/client";
|
|
5
|
+
import type { AppStoreScreenshotAgentResult } from "../../../../lib/tools/app-store-screenshot-generator-schema";
|
|
6
|
+
|
|
7
|
+
import { waitForAgentResult } from "./generation";
|
|
8
|
+
|
|
9
|
+
const prompt = "Generate a cohesive app store screenshot set from the supplied product UI. ".repeat(4);
|
|
10
|
+
|
|
11
|
+
function agentResult(
|
|
12
|
+
assetCount: number,
|
|
13
|
+
reportStatus: AppStoreScreenshotAgentResult["reportStatus"] = "partial",
|
|
14
|
+
): AppStoreScreenshotAgentResult {
|
|
15
|
+
return {
|
|
16
|
+
promptId: "tools.app-store-screenshot-generator.agent",
|
|
17
|
+
promptVersion: "2026-07-03.2",
|
|
18
|
+
reportStatus,
|
|
19
|
+
summary: `${assetCount} screenshots planned`,
|
|
20
|
+
prompt,
|
|
21
|
+
assets: Array.from({ length: assetCount }, (_, index) => ({
|
|
22
|
+
id: `asset-${index + 1}`,
|
|
23
|
+
platform: "apple" as const,
|
|
24
|
+
format: "iphone" as const,
|
|
25
|
+
title: `Screenshot ${index + 1}`,
|
|
26
|
+
width: 1290,
|
|
27
|
+
height: 2796,
|
|
28
|
+
slot: index + 1,
|
|
29
|
+
setSize: 10,
|
|
30
|
+
imageTaskId: `image-task-${index + 1}`,
|
|
31
|
+
})),
|
|
32
|
+
failures: [],
|
|
33
|
+
totalAssets: 10,
|
|
34
|
+
completedAssets: assetCount,
|
|
35
|
+
failedAssets: 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registerTaskSequence(
|
|
40
|
+
responses: Array<{
|
|
41
|
+
status: string;
|
|
42
|
+
data: AppStoreScreenshotAgentResult | null;
|
|
43
|
+
errorCode?: "task_failed";
|
|
44
|
+
error?: string;
|
|
45
|
+
}>,
|
|
46
|
+
) {
|
|
47
|
+
let calls = 0;
|
|
48
|
+
registerToolsRuntimeHostClient({
|
|
49
|
+
fetchAppStoreScreenshotSetTask: async () => {
|
|
50
|
+
const response = responses[Math.min(calls, responses.length - 1)];
|
|
51
|
+
calls += 1;
|
|
52
|
+
return response;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return () => calls;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fastPolling = { pollIntervalMs: 0, timeoutMs: 1_000 };
|
|
59
|
+
|
|
60
|
+
describe("waitForAgentResult", () => {
|
|
61
|
+
it("keeps following after the first partial batch until the expected asset count arrives", async () => {
|
|
62
|
+
const firstBatch = agentResult(4);
|
|
63
|
+
const completeBatch = agentResult(10);
|
|
64
|
+
const calls = registerTaskSequence([
|
|
65
|
+
{ status: "SUCCEEDED", data: firstBatch },
|
|
66
|
+
{ status: "SUCCEEDED", data: completeBatch },
|
|
67
|
+
]);
|
|
68
|
+
const progressCounts: number[] = [];
|
|
69
|
+
|
|
70
|
+
const result = await waitForAgentResult(
|
|
71
|
+
"task-expected-count",
|
|
72
|
+
(progress) => progressCounts.push(progress.assets.length),
|
|
73
|
+
10,
|
|
74
|
+
fastPolling,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
assert.equal(calls(), 2);
|
|
78
|
+
assert.deepEqual(progressCounts, [4, 10]);
|
|
79
|
+
assert.equal(result.assets.length, 10);
|
|
80
|
+
assert.equal(result.reportStatus, "partial");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stops on an explicit final snapshot even when it has fewer assets than expected", async () => {
|
|
84
|
+
const firstBatch = agentResult(4);
|
|
85
|
+
const finalBatch = agentResult(4, "final");
|
|
86
|
+
const calls = registerTaskSequence([
|
|
87
|
+
{ status: "SUCCEEDED", data: firstBatch },
|
|
88
|
+
{ status: "SUCCEEDED", data: finalBatch },
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const result = await waitForAgentResult(
|
|
92
|
+
"task-final",
|
|
93
|
+
() => undefined,
|
|
94
|
+
10,
|
|
95
|
+
fastPolling,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
assert.equal(calls(), 2);
|
|
99
|
+
assert.equal(result.reportStatus, "final");
|
|
100
|
+
assert.equal(result.assets.length, 4);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns the last usable partial snapshot when the task fails terminally", async () => {
|
|
104
|
+
const firstBatch = agentResult(4);
|
|
105
|
+
const calls = registerTaskSequence([
|
|
106
|
+
{ status: "RUNNING", data: firstBatch },
|
|
107
|
+
{
|
|
108
|
+
status: "FAILED",
|
|
109
|
+
data: null,
|
|
110
|
+
errorCode: "task_failed",
|
|
111
|
+
error: "background follower failed",
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const result = await waitForAgentResult(
|
|
116
|
+
"task-terminal",
|
|
117
|
+
() => undefined,
|
|
118
|
+
10,
|
|
119
|
+
fastPolling,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
assert.equal(calls(), 2);
|
|
123
|
+
assert.equal(result.reportStatus, "partial");
|
|
124
|
+
assert.equal(result.assets.length, 4);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -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 =
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
280
|
-
|
|
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) =>
|
|
500
|
+
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
|
463
501
|
}
|
|
464
502
|
|
|
465
503
|
export function formatGenerationError(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
AppStoreScreenshotAsset,
|
|
6
|
+
AppStoreScreenshotGeneratorInput,
|
|
7
|
+
} from "../../../../lib/tools/app-store-screenshot-generator-schema";
|
|
8
|
+
|
|
9
|
+
import { createGeneratedAssetZipEntries } from "./input-media";
|
|
10
|
+
|
|
11
|
+
const input = {
|
|
12
|
+
source: "upload",
|
|
13
|
+
productUrl: "",
|
|
14
|
+
hasScreenshot: true,
|
|
15
|
+
screenshotName: "source.png",
|
|
16
|
+
screenshotDataUrl: "",
|
|
17
|
+
screenshotReferences: [],
|
|
18
|
+
logoName: "",
|
|
19
|
+
logoDataUrl: "",
|
|
20
|
+
productName: "Tomako",
|
|
21
|
+
productBenefits: "",
|
|
22
|
+
assetCount: 1,
|
|
23
|
+
outputLanguage: "en",
|
|
24
|
+
platform: "apple",
|
|
25
|
+
style: "clean",
|
|
26
|
+
palette: "auto",
|
|
27
|
+
layout: "mixed",
|
|
28
|
+
headline: "",
|
|
29
|
+
subheadline: "",
|
|
30
|
+
} satisfies AppStoreScreenshotGeneratorInput;
|
|
31
|
+
|
|
32
|
+
describe("app store screenshot ZIP entries", () => {
|
|
33
|
+
it("includes the PNG and existing SVG wrapper for each generated asset", async () => {
|
|
34
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><metadata>${"prompt ".repeat(20)}</metadata></svg>`;
|
|
35
|
+
const asset = {
|
|
36
|
+
id: "hero",
|
|
37
|
+
platform: "apple",
|
|
38
|
+
format: "iphone",
|
|
39
|
+
title: "Core benefit",
|
|
40
|
+
width: 1290,
|
|
41
|
+
height: 2796,
|
|
42
|
+
imageUrl: "https://example.com/hero.png",
|
|
43
|
+
svg,
|
|
44
|
+
} satisfies AppStoreScreenshotAsset;
|
|
45
|
+
|
|
46
|
+
const files = await createGeneratedAssetZipEntries(
|
|
47
|
+
[asset],
|
|
48
|
+
input,
|
|
49
|
+
async () => new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
files.map((file) => file.path),
|
|
54
|
+
[
|
|
55
|
+
"01-tomako-apple-iphone-core-benefit.png",
|
|
56
|
+
"01-tomako-apple-iphone-core-benefit.svg",
|
|
57
|
+
],
|
|
58
|
+
);
|
|
59
|
+
assert.deepEqual([...files[0]!.data], [137, 80, 78, 71]);
|
|
60
|
+
assert.equal(new TextDecoder().decode(files[1]!.data), svg);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not advertise a missing SVG by adding an empty file", async () => {
|
|
64
|
+
const asset = {
|
|
65
|
+
id: "hero",
|
|
66
|
+
platform: "apple",
|
|
67
|
+
format: "iphone",
|
|
68
|
+
width: 1290,
|
|
69
|
+
height: 2796,
|
|
70
|
+
imageUrl: "https://example.com/hero.png",
|
|
71
|
+
} satisfies AppStoreScreenshotAsset;
|
|
72
|
+
|
|
73
|
+
const files = await createGeneratedAssetZipEntries(
|
|
74
|
+
[asset],
|
|
75
|
+
input,
|
|
76
|
+
async () => new Blob([new Uint8Array([1])], { type: "image/png" }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(files.map((file) => file.path), ["01-tomako-apple-iphone-hero.png"]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
|
|
6
6
|
import { renderAssetToPngBlob } from "./generation";
|
|
7
7
|
import type { SourceKey } from "./types";
|
|
8
|
-
import { createZipBlob, slugForFileName, type ZipFileInput } from "./zip";
|
|
8
|
+
import { createZipBlob, encodeText, slugForFileName, type ZipFileInput } from "./zip";
|
|
9
9
|
|
|
10
10
|
export function inferSource(input: AppStoreScreenshotGeneratorInput): SourceKey {
|
|
11
11
|
const hasUrl = input.productUrl.trim().length > 0;
|
|
@@ -137,22 +137,34 @@ export async function createGeneratedAssetsZip(
|
|
|
137
137
|
assets: readonly AppStoreScreenshotAsset[],
|
|
138
138
|
input: AppStoreScreenshotGeneratorInput,
|
|
139
139
|
): Promise<Blob> {
|
|
140
|
+
return createZipBlob(await createGeneratedAssetZipEntries(assets, input));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function createGeneratedAssetZipEntries(
|
|
144
|
+
assets: readonly AppStoreScreenshotAsset[],
|
|
145
|
+
input: AppStoreScreenshotGeneratorInput,
|
|
146
|
+
renderPng: (asset: AppStoreScreenshotAsset) => Promise<Blob> = renderAssetToPngBlob,
|
|
147
|
+
): Promise<ZipFileInput[]> {
|
|
140
148
|
const files: ZipFileInput[] = [];
|
|
141
149
|
const usedPaths = new Set<string>();
|
|
142
150
|
|
|
143
151
|
for (const [index, asset] of assets.entries()) {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
usedPaths,
|
|
147
|
-
`${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}.png`,
|
|
148
|
-
);
|
|
152
|
+
const fileStem = `${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}`;
|
|
153
|
+
const blob = await renderPng(asset);
|
|
149
154
|
files.push({
|
|
150
|
-
path,
|
|
155
|
+
path: uniqueZipPath(usedPaths, `${fileStem}.png`),
|
|
151
156
|
data: new Uint8Array(await blob.arrayBuffer()),
|
|
152
157
|
});
|
|
158
|
+
|
|
159
|
+
if (asset.svg) {
|
|
160
|
+
files.push({
|
|
161
|
+
path: uniqueZipPath(usedPaths, `${fileStem}.svg`),
|
|
162
|
+
data: encodeText(asset.svg),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
153
165
|
}
|
|
154
166
|
|
|
155
|
-
return
|
|
167
|
+
return files;
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
export function uniqueZipPath(usedPaths: Set<string>, path: string) {
|
package/src/features/tools/cold-start-channel-selector/widget/cold-start-channel-selector.tsx
CHANGED
|
@@ -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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|