@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomako/tools-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Shared interactive tools runtime for Tomako-FE workspace dialogs and Tomako-SEO tool pages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./components/tools/engine/package-widget-runtime": "./src/components/tools/engine/package-widget-runtime.tsx",
|
|
32
32
|
"./components/tools/engine/package-widget": "./src/components/tools/engine/package-widget.tsx",
|
|
33
33
|
"./components/tools/engine/result-view": "./src/components/tools/engine/result-view.tsx",
|
|
34
|
+
"./components/tools/engine/task-recovery": "./src/components/tools/engine/task-recovery.ts",
|
|
34
35
|
"./components/tools/engine/use-legacy-llm-task": "./src/components/tools/engine/use-legacy-llm-task.ts",
|
|
35
36
|
"./components/tools/engine/use-tool-task": "./src/components/tools/engine/use-tool-task.ts",
|
|
36
37
|
"./components/tools/market-forecast-report": "./src/components/tools/market-forecast-report.tsx",
|
|
@@ -72,7 +72,7 @@ export function PackageWidgetRuntime({
|
|
|
72
72
|
}) {
|
|
73
73
|
const locale = useLocale() as AppLocale;
|
|
74
74
|
const t = useTranslations("toolsPages.engine");
|
|
75
|
-
const { state, start, reset } = useToolTask(pkg, locale);
|
|
75
|
+
const { state, start, reset, recoveredValues } = useToolTask(pkg, locale);
|
|
76
76
|
const resultRef = useRef<HTMLElement | null>(null);
|
|
77
77
|
const artifactRegistrationRef = useRef<string | null>(null);
|
|
78
78
|
const { brief: activityBrief } = useToolHandoff();
|
|
@@ -85,6 +85,10 @@ export function PackageWidgetRuntime({
|
|
|
85
85
|
DEFAULT_DIRECT_MODEL_OPTION,
|
|
86
86
|
);
|
|
87
87
|
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (recoveredValues) setValues(recoveredValues);
|
|
90
|
+
}, [recoveredValues]);
|
|
91
|
+
|
|
88
92
|
const busy = state.phase === "submitting" || state.phase === "running";
|
|
89
93
|
const isWorkspace = variant === "workspace";
|
|
90
94
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
clearToolTaskRecovery,
|
|
6
|
+
persistToolTaskRecovery,
|
|
7
|
+
readToolTaskRecovery,
|
|
8
|
+
toolTaskRecoveryStorageKey,
|
|
9
|
+
} from "./task-recovery";
|
|
10
|
+
|
|
11
|
+
function memoryStorage() {
|
|
12
|
+
const values = new Map<string, string>();
|
|
13
|
+
return {
|
|
14
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
15
|
+
setItem: (key: string, value: string) => values.set(key, value),
|
|
16
|
+
removeItem: (key: string) => values.delete(key),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("tool task recovery", () => {
|
|
21
|
+
it("restores task identity and input without storing server result data", () => {
|
|
22
|
+
const storage = memoryStorage();
|
|
23
|
+
persistToolTaskRecovery(
|
|
24
|
+
"competitor-analysis",
|
|
25
|
+
"llm-0123456789abcdef",
|
|
26
|
+
{ url: "https://example.com" },
|
|
27
|
+
1_000,
|
|
28
|
+
storage,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
readToolTaskRecovery("competitor-analysis", {
|
|
33
|
+
nowMs: 2_000,
|
|
34
|
+
storage,
|
|
35
|
+
}),
|
|
36
|
+
{
|
|
37
|
+
version: 1,
|
|
38
|
+
taskId: "llm-0123456789abcdef",
|
|
39
|
+
input: { url: "https://example.com" },
|
|
40
|
+
startedAtMs: 1_000,
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
assert.equal(
|
|
44
|
+
storage.getItem(toolTaskRecoveryStorageKey("competitor-analysis"))?.includes("result"),
|
|
45
|
+
false,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("removes expired or invalid recovery records", () => {
|
|
50
|
+
const storage = memoryStorage();
|
|
51
|
+
persistToolTaskRecovery("cold-start", "llm-task", { audience: "founders" }, 1_000, storage);
|
|
52
|
+
assert.equal(
|
|
53
|
+
readToolTaskRecovery("cold-start", { nowMs: 10_000, ttlMs: 100, storage }),
|
|
54
|
+
null,
|
|
55
|
+
);
|
|
56
|
+
assert.equal(storage.getItem(toolTaskRecoveryStorageKey("cold-start")), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clears recovery on an explicit reset", () => {
|
|
60
|
+
const storage = memoryStorage();
|
|
61
|
+
persistToolTaskRecovery("email-template-generator", "llm-task", {}, 1_000, storage);
|
|
62
|
+
clearToolTaskRecovery("email-template-generator", storage);
|
|
63
|
+
assert.equal(readToolTaskRecovery("email-template-generator", { storage }), null);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const TOOL_TASK_RECOVERY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
export type ToolTaskRecovery<TInput = unknown> = {
|
|
4
|
+
version: 1;
|
|
5
|
+
taskId: string;
|
|
6
|
+
input: TInput;
|
|
7
|
+
startedAtMs: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type RecoveryStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
11
|
+
|
|
12
|
+
function browserSessionStorage(): RecoveryStorage | null {
|
|
13
|
+
if (typeof window === "undefined") return null;
|
|
14
|
+
try {
|
|
15
|
+
return window.sessionStorage;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toolTaskRecoveryStorageKey(slug: string): string {
|
|
22
|
+
return `tomako.tool-task.${slug.trim().toLowerCase()}.v1`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Persist only task identity and user input; auth and result data stay server-owned. */
|
|
26
|
+
export function persistToolTaskRecovery<TInput>(
|
|
27
|
+
slug: string,
|
|
28
|
+
taskId: string,
|
|
29
|
+
input: TInput,
|
|
30
|
+
startedAtMs = Date.now(),
|
|
31
|
+
storage: RecoveryStorage | null = browserSessionStorage(),
|
|
32
|
+
): ToolTaskRecovery<TInput> | null {
|
|
33
|
+
if (!storage || !slug.trim() || !taskId.trim()) return null;
|
|
34
|
+
const recovery: ToolTaskRecovery<TInput> = {
|
|
35
|
+
version: 1,
|
|
36
|
+
taskId: taskId.trim(),
|
|
37
|
+
input,
|
|
38
|
+
startedAtMs,
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
storage.setItem(toolTaskRecoveryStorageKey(slug), JSON.stringify(recovery));
|
|
42
|
+
return recovery;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readToolTaskRecovery<TInput>(
|
|
49
|
+
slug: string,
|
|
50
|
+
options: {
|
|
51
|
+
ttlMs?: number;
|
|
52
|
+
nowMs?: number;
|
|
53
|
+
storage?: RecoveryStorage | null;
|
|
54
|
+
parseInput?: (input: unknown) => TInput | null;
|
|
55
|
+
} = {},
|
|
56
|
+
): ToolTaskRecovery<TInput> | null {
|
|
57
|
+
const storage = options.storage === undefined
|
|
58
|
+
? browserSessionStorage()
|
|
59
|
+
: options.storage;
|
|
60
|
+
if (!storage || !slug.trim()) return null;
|
|
61
|
+
const key = toolTaskRecoveryStorageKey(slug);
|
|
62
|
+
try {
|
|
63
|
+
const raw = storage.getItem(key);
|
|
64
|
+
if (!raw) return null;
|
|
65
|
+
const parsed = JSON.parse(raw) as Partial<ToolTaskRecovery<unknown>>;
|
|
66
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
67
|
+
const ttlMs = options.ttlMs ?? TOOL_TASK_RECOVERY_TTL_MS;
|
|
68
|
+
if (
|
|
69
|
+
parsed.version !== 1 ||
|
|
70
|
+
typeof parsed.taskId !== "string" ||
|
|
71
|
+
!parsed.taskId.trim() ||
|
|
72
|
+
typeof parsed.startedAtMs !== "number" ||
|
|
73
|
+
!Number.isFinite(parsed.startedAtMs) ||
|
|
74
|
+
parsed.startedAtMs > nowMs + 60_000 ||
|
|
75
|
+
nowMs - parsed.startedAtMs > ttlMs
|
|
76
|
+
) {
|
|
77
|
+
storage.removeItem(key);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const input = options.parseInput
|
|
81
|
+
? options.parseInput(parsed.input)
|
|
82
|
+
: (parsed.input as TInput);
|
|
83
|
+
if (input === null) {
|
|
84
|
+
storage.removeItem(key);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
taskId: parsed.taskId.trim(),
|
|
90
|
+
input,
|
|
91
|
+
startedAtMs: parsed.startedAtMs,
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
storage.removeItem(key);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function clearToolTaskRecovery(
|
|
100
|
+
slug: string,
|
|
101
|
+
storage: RecoveryStorage | null = browserSessionStorage(),
|
|
102
|
+
) {
|
|
103
|
+
if (!storage || !slug.trim()) return;
|
|
104
|
+
try {
|
|
105
|
+
storage.removeItem(toolTaskRecoveryStorageKey(slug));
|
|
106
|
+
} catch {
|
|
107
|
+
// Recovery is best effort and must never block a user reset.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
watchLlmTask,
|
|
@@ -41,7 +41,11 @@ export function useLegacyLlmTaskWatch<TResult>() {
|
|
|
41
41
|
taskId: string,
|
|
42
42
|
pollOnce: (taskId: string) => Promise<LlmTaskWatchPollOutcome>,
|
|
43
43
|
handlers: LegacyTaskWatchHandlers<TResult>,
|
|
44
|
-
options?: {
|
|
44
|
+
options?: {
|
|
45
|
+
pollIntervalMs?: number;
|
|
46
|
+
graceWindowMs?: number;
|
|
47
|
+
startedAtMs?: number;
|
|
48
|
+
},
|
|
45
49
|
) => {
|
|
46
50
|
stop();
|
|
47
51
|
stopRef.current = watchLlmTask({
|
|
@@ -55,10 +59,19 @@ export function useLegacyLlmTaskWatch<TResult>() {
|
|
|
55
59
|
isCancelled: () => cancelledRef.current,
|
|
56
60
|
pollIntervalMs: options?.pollIntervalMs,
|
|
57
61
|
graceWindowMs: options?.graceWindowMs,
|
|
62
|
+
startedAtMs: options?.startedAtMs,
|
|
58
63
|
});
|
|
59
64
|
},
|
|
60
65
|
[stop],
|
|
61
66
|
);
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
const isCancelled = useCallback(() => cancelledRef.current, []);
|
|
69
|
+
|
|
70
|
+
// Consumers legitimately use the controller as an effect dependency. Keep
|
|
71
|
+
// its identity stable so a state update does not run their cleanup and stop
|
|
72
|
+
// the task watcher that was just started.
|
|
73
|
+
return useMemo(
|
|
74
|
+
() => ({ start, stop, isCancelled }),
|
|
75
|
+
[isCancelled, start, stop],
|
|
76
|
+
);
|
|
64
77
|
}
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
submitPackageTask,
|
|
16
16
|
} from "../../../host/client";
|
|
17
17
|
import { watchLlmTask } from "../../../host/client";
|
|
18
|
+
import {
|
|
19
|
+
clearToolTaskRecovery,
|
|
20
|
+
persistToolTaskRecovery,
|
|
21
|
+
readToolTaskRecovery,
|
|
22
|
+
} from "./task-recovery";
|
|
18
23
|
|
|
19
24
|
export type EngineErrorCode =
|
|
20
25
|
| "validation"
|
|
@@ -47,6 +52,7 @@ export function useToolTask<TResult = unknown>(
|
|
|
47
52
|
locale: AppLocale,
|
|
48
53
|
) {
|
|
49
54
|
const [state, setState] = useState<ToolTaskState<TResult>>({ phase: "idle" });
|
|
55
|
+
const [recoveredValues, setRecoveredValues] = useState<FormValues | null>(null);
|
|
50
56
|
const cancelledRef = useRef(false);
|
|
51
57
|
const stopWatchRef = useRef<(() => void) | null>(null);
|
|
52
58
|
const lastSnapshotRef = useRef<{ status: string; error?: string }>({
|
|
@@ -61,11 +67,76 @@ export function useToolTask<TResult = unknown>(
|
|
|
61
67
|
};
|
|
62
68
|
}, []);
|
|
63
69
|
|
|
70
|
+
const startAgentWatch = useCallback((taskId: string, startedAtMs?: number) => {
|
|
71
|
+
if (pkg.runtime.lane !== "agent") return;
|
|
72
|
+
const runtime = pkg.runtime;
|
|
73
|
+
setState({ phase: "running", taskId, status: startedAtMs ? "RESTORING" : "PENDING" });
|
|
74
|
+
stopWatchRef.current = watchLlmTask({
|
|
75
|
+
taskId,
|
|
76
|
+
startedAtMs,
|
|
77
|
+
pollIntervalMs: runtime.watch?.pollIntervalMs,
|
|
78
|
+
graceWindowMs: runtime.watch?.graceWindowMs,
|
|
79
|
+
isCancelled: () => cancelledRef.current,
|
|
80
|
+
pollOnce: async (id: string) => {
|
|
81
|
+
const snapshot = await fetchPackageTask<TResult>(pkg, id);
|
|
82
|
+
lastSnapshotRef.current = { status: snapshot.status, error: snapshot.error };
|
|
83
|
+
if (snapshot.data) {
|
|
84
|
+
return { settled: true, kind: "success", data: snapshot.data };
|
|
85
|
+
}
|
|
86
|
+
if (snapshot.status === "FAILED") {
|
|
87
|
+
return { settled: true, kind: "error", category: "task" };
|
|
88
|
+
}
|
|
89
|
+
if (snapshot.status === "RESULT_INVALID") {
|
|
90
|
+
return { settled: true, kind: "error", category: "schema" };
|
|
91
|
+
}
|
|
92
|
+
return { settled: false, pendingWriteback: true };
|
|
93
|
+
},
|
|
94
|
+
handlers: {
|
|
95
|
+
onStatus: (status: string) => {
|
|
96
|
+
setState((prev) =>
|
|
97
|
+
prev.phase === "running" ? { ...prev, status } : prev,
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
onSuccess: (data: TResult) => {
|
|
101
|
+
setState({ phase: "done", taskId, data: data as TResult });
|
|
102
|
+
},
|
|
103
|
+
onError: (category: string) => {
|
|
104
|
+
const { status, error } = lastSnapshotRef.current;
|
|
105
|
+
let code: EngineErrorCode;
|
|
106
|
+
if (category === "timeout") {
|
|
107
|
+
code =
|
|
108
|
+
status === "SUCCEEDED" || status === "AWAITING_RESULT"
|
|
109
|
+
? "resultWritebackMissing"
|
|
110
|
+
: "resultTimeout";
|
|
111
|
+
} else if (category === "schema") {
|
|
112
|
+
code =
|
|
113
|
+
error === "wrong_result_type"
|
|
114
|
+
? "wrongResultType"
|
|
115
|
+
: "invalidResultSchema";
|
|
116
|
+
} else {
|
|
117
|
+
code = "taskFailed";
|
|
118
|
+
}
|
|
119
|
+
setState({ phase: "error", code, taskId });
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}, [pkg]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (pkg.runtime.lane !== "agent") return;
|
|
127
|
+
const recovered = readToolTaskRecovery<FormValues>(pkg.meta.slug);
|
|
128
|
+
if (!recovered) return;
|
|
129
|
+
setRecoveredValues(recovered.input);
|
|
130
|
+
startAgentWatch(recovered.taskId, recovered.startedAtMs);
|
|
131
|
+
}, [pkg.meta.slug, pkg.runtime.lane, startAgentWatch]);
|
|
132
|
+
|
|
64
133
|
const reset = useCallback(() => {
|
|
65
134
|
stopWatchRef.current?.();
|
|
66
135
|
stopWatchRef.current = null;
|
|
136
|
+
clearToolTaskRecovery(pkg.meta.slug);
|
|
137
|
+
setRecoveredValues(null);
|
|
67
138
|
setState({ phase: "idle" });
|
|
68
|
-
}, []);
|
|
139
|
+
}, [pkg.meta.slug]);
|
|
69
140
|
|
|
70
141
|
const start = useCallback(
|
|
71
142
|
async (values: FormValues, options?: { directModel?: string }) => {
|
|
@@ -137,59 +208,12 @@ export function useToolTask<TResult = unknown>(
|
|
|
137
208
|
}
|
|
138
209
|
|
|
139
210
|
if (cancelledRef.current) return;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
taskId,
|
|
144
|
-
pollIntervalMs: pkg.runtime.watch?.pollIntervalMs,
|
|
145
|
-
graceWindowMs: pkg.runtime.watch?.graceWindowMs,
|
|
146
|
-
isCancelled: () => cancelledRef.current,
|
|
147
|
-
pollOnce: async (id: string) => {
|
|
148
|
-
const snapshot = await fetchPackageTask<TResult>(pkg, id);
|
|
149
|
-
lastSnapshotRef.current = { status: snapshot.status, error: snapshot.error };
|
|
150
|
-
if (snapshot.data) {
|
|
151
|
-
return { settled: true, kind: "success", data: snapshot.data };
|
|
152
|
-
}
|
|
153
|
-
if (snapshot.status === "FAILED") {
|
|
154
|
-
return { settled: true, kind: "error", category: "task" };
|
|
155
|
-
}
|
|
156
|
-
if (snapshot.status === "RESULT_INVALID") {
|
|
157
|
-
return { settled: true, kind: "error", category: "schema" };
|
|
158
|
-
}
|
|
159
|
-
return { settled: false, pendingWriteback: true };
|
|
160
|
-
},
|
|
161
|
-
handlers: {
|
|
162
|
-
onStatus: (status: string) => {
|
|
163
|
-
setState((prev) =>
|
|
164
|
-
prev.phase === "running" ? { ...prev, status } : prev,
|
|
165
|
-
);
|
|
166
|
-
},
|
|
167
|
-
onSuccess: (data: TResult) => {
|
|
168
|
-
setState({ phase: "done", taskId, data: data as TResult });
|
|
169
|
-
},
|
|
170
|
-
onError: (category: string) => {
|
|
171
|
-
const { status, error } = lastSnapshotRef.current;
|
|
172
|
-
let code: EngineErrorCode;
|
|
173
|
-
if (category === "timeout") {
|
|
174
|
-
code =
|
|
175
|
-
status === "SUCCEEDED" || status === "AWAITING_RESULT"
|
|
176
|
-
? "resultWritebackMissing"
|
|
177
|
-
: "resultTimeout";
|
|
178
|
-
} else if (category === "schema") {
|
|
179
|
-
code =
|
|
180
|
-
error === "wrong_result_type"
|
|
181
|
-
? "wrongResultType"
|
|
182
|
-
: "invalidResultSchema";
|
|
183
|
-
} else {
|
|
184
|
-
code = "taskFailed";
|
|
185
|
-
}
|
|
186
|
-
setState({ phase: "error", code, taskId });
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
});
|
|
211
|
+
const startedAtMs = Date.now();
|
|
212
|
+
persistToolTaskRecovery(pkg.meta.slug, taskId, values, startedAtMs);
|
|
213
|
+
startAgentWatch(taskId, startedAtMs);
|
|
190
214
|
},
|
|
191
|
-
[locale, pkg],
|
|
215
|
+
[locale, pkg, startAgentWatch],
|
|
192
216
|
);
|
|
193
217
|
|
|
194
|
-
return { state, start, reset } as const;
|
|
218
|
+
return { state, start, reset, recoveredValues } as const;
|
|
195
219
|
}
|
package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
buildGeneratedAssetSlots,
|
|
45
45
|
clampAssetCount,
|
|
46
46
|
dimensionsForAssetFormat,
|
|
47
|
-
|
|
47
|
+
gridClassForAssetFormat,
|
|
48
48
|
} from "./asset-slots";
|
|
49
49
|
import {
|
|
50
50
|
LOGO_REFERENCE_MAX_DATA_URL_LENGTH,
|
|
@@ -84,6 +84,13 @@ import type {
|
|
|
84
84
|
OutputLanguageKey,
|
|
85
85
|
StyleKey
|
|
86
86
|
} from "./types";
|
|
87
|
+
import {
|
|
88
|
+
clearToolTaskRecovery,
|
|
89
|
+
persistToolTaskRecovery,
|
|
90
|
+
readToolTaskRecovery,
|
|
91
|
+
} from "../../../../components/tools/engine/task-recovery";
|
|
92
|
+
|
|
93
|
+
const RECOVERY_SLUG = "app-store-screenshot-generator";
|
|
87
94
|
|
|
88
95
|
export function AppStoreScreenshotGenerator() {
|
|
89
96
|
const locale = useLocale();
|
|
@@ -114,6 +121,21 @@ export function AppStoreScreenshotGenerator() {
|
|
|
114
121
|
const [logoDragActive, setLogoDragActive] = useState(false);
|
|
115
122
|
const { brief: activityBrief } = useToolHandoff();
|
|
116
123
|
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
|
|
126
|
+
parseInput: (value) => {
|
|
127
|
+
const parsed = appStoreScreenshotGeneratorInputSchema.safeParse(value);
|
|
128
|
+
return parsed.success ? parsed.data : null;
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (!recovered) return;
|
|
132
|
+
setDraft(recovered.input);
|
|
133
|
+
setScreenshotReferences(recovered.input.screenshotReferences);
|
|
134
|
+
void runGeneration(recovered.input, recovered.taskId, recovered.startedAtMs);
|
|
135
|
+
// Restore only once for this mounted tool route.
|
|
136
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
117
139
|
useEffect(() => {
|
|
118
140
|
if (!activityBrief || busy || !output) return;
|
|
119
141
|
publishActivityArtifact(
|
|
@@ -138,9 +160,12 @@ export function AppStoreScreenshotGenerator() {
|
|
|
138
160
|
() => downloadableAssets.filter((asset) => downloadSelectionIds.has(asset.id)),
|
|
139
161
|
[downloadSelectionIds, downloadableAssets],
|
|
140
162
|
);
|
|
163
|
+
const soleDownloadableAsset = downloadableAssets.length === 1 ? downloadableAssets[0] : null;
|
|
141
164
|
const downloadButtonLabel = downloadSelectionMode
|
|
142
165
|
? `${t("download")} ${selectedDownloadAssets.length}/${downloadableAssets.length}`
|
|
143
|
-
:
|
|
166
|
+
: soleDownloadableAsset
|
|
167
|
+
? t("downloadPng")
|
|
168
|
+
: t("download");
|
|
144
169
|
|
|
145
170
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
146
171
|
event.preventDefault();
|
|
@@ -179,8 +204,16 @@ export function AppStoreScreenshotGenerator() {
|
|
|
179
204
|
return;
|
|
180
205
|
}
|
|
181
206
|
|
|
207
|
+
await runGeneration(parsed.data);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runGeneration(
|
|
211
|
+
input: AppStoreScreenshotGeneratorInput,
|
|
212
|
+
recoveredTaskId?: string,
|
|
213
|
+
recoveredStartedAtMs?: number,
|
|
214
|
+
) {
|
|
182
215
|
try {
|
|
183
|
-
setDraft(
|
|
216
|
+
setDraft(input);
|
|
184
217
|
setBusy(true);
|
|
185
218
|
setOutput(null);
|
|
186
219
|
setSelectedId(null);
|
|
@@ -190,16 +223,16 @@ export function AppStoreScreenshotGenerator() {
|
|
|
190
223
|
setGenerationProgress({
|
|
191
224
|
phase: "submitting",
|
|
192
225
|
completedCount: 0,
|
|
193
|
-
totalCount: expectedAssetCount(
|
|
226
|
+
totalCount: expectedAssetCount(input),
|
|
194
227
|
failedCount: 0,
|
|
195
|
-
estimatedRemainingSeconds: initialEstimatedSeconds(
|
|
228
|
+
estimatedRemainingSeconds: initialEstimatedSeconds(input),
|
|
196
229
|
});
|
|
197
230
|
requestAnimationFrame(() => {
|
|
198
231
|
resultRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
199
232
|
});
|
|
200
233
|
|
|
201
234
|
const generated = await generateStoreAssets(
|
|
202
|
-
|
|
235
|
+
input,
|
|
203
236
|
locale,
|
|
204
237
|
(progress) => {
|
|
205
238
|
const parsedProgressOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(
|
|
@@ -226,11 +259,18 @@ export function AppStoreScreenshotGenerator() {
|
|
|
226
259
|
updateGenerationProgress({
|
|
227
260
|
phase: "planning",
|
|
228
261
|
completedCount: 0,
|
|
229
|
-
totalCount: expectedAssetCount(
|
|
262
|
+
totalCount: expectedAssetCount(input),
|
|
230
263
|
failedCount: 0,
|
|
231
|
-
estimatedRemainingSeconds: initialEstimatedSeconds(
|
|
264
|
+
estimatedRemainingSeconds: initialEstimatedSeconds(input),
|
|
232
265
|
});
|
|
233
266
|
},
|
|
267
|
+
{
|
|
268
|
+
taskId: recoveredTaskId,
|
|
269
|
+
startedAtMs: recoveredStartedAtMs,
|
|
270
|
+
onTaskSubmitted: (taskId, startedAtMs) => {
|
|
271
|
+
persistToolTaskRecovery(RECOVERY_SLUG, taskId, input, startedAtMs);
|
|
272
|
+
},
|
|
273
|
+
},
|
|
234
274
|
);
|
|
235
275
|
const parsedOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(generated.output);
|
|
236
276
|
|
|
@@ -286,6 +326,7 @@ export function AppStoreScreenshotGenerator() {
|
|
|
286
326
|
}
|
|
287
327
|
|
|
288
328
|
function reset() {
|
|
329
|
+
clearToolTaskRecovery(RECOVERY_SLUG);
|
|
289
330
|
setOutput(null);
|
|
290
331
|
setSelectedId(null);
|
|
291
332
|
setLightboxIndex(null);
|
|
@@ -451,6 +492,14 @@ export function AppStoreScreenshotGenerator() {
|
|
|
451
492
|
if (!downloadableAssets.length || downloading) return;
|
|
452
493
|
setDownloadError(null);
|
|
453
494
|
|
|
495
|
+
if (soleDownloadableAsset) {
|
|
496
|
+
await downloadAssets([soleDownloadableAsset], {
|
|
497
|
+
forceZip: false,
|
|
498
|
+
zipName: buildScreenshotsZipName(draft, "selected"),
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
454
503
|
if (!downloadSelectionMode) {
|
|
455
504
|
setDownloadSelectionMode(true);
|
|
456
505
|
setDownloadSelectionIds(
|
|
@@ -483,6 +532,15 @@ export function AppStoreScreenshotGenerator() {
|
|
|
483
532
|
});
|
|
484
533
|
}
|
|
485
534
|
|
|
535
|
+
function handleDownloadSvgClick(asset: AppStoreScreenshotAsset) {
|
|
536
|
+
if (!asset.svg || downloading) return;
|
|
537
|
+
setDownloadError(null);
|
|
538
|
+
downloadBlob(
|
|
539
|
+
new Blob([asset.svg], { type: "image/svg+xml;charset=utf-8" }),
|
|
540
|
+
`${assetFileBase(draft, asset)}.svg`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
486
544
|
function cancelDownloadSelection() {
|
|
487
545
|
setDownloadSelectionMode(false);
|
|
488
546
|
setDownloadSelectionIds(new Set());
|
|
@@ -960,6 +1018,18 @@ export function AppStoreScreenshotGenerator() {
|
|
|
960
1018
|
{downloading ? t("downloading") : downloadButtonLabel}
|
|
961
1019
|
</span>
|
|
962
1020
|
</Button>
|
|
1021
|
+
{soleDownloadableAsset?.svg ? (
|
|
1022
|
+
<Button
|
|
1023
|
+
type="button"
|
|
1024
|
+
variant="outline"
|
|
1025
|
+
onClick={() => handleDownloadSvgClick(soleDownloadableAsset)}
|
|
1026
|
+
disabled={downloading}
|
|
1027
|
+
className="h-10 rounded-lg border-[#D8D4CA] bg-white px-4 text-xs font-semibold text-[#111111] shadow-none hover:bg-[#F4F4F4] disabled:pointer-events-none disabled:opacity-55"
|
|
1028
|
+
>
|
|
1029
|
+
<Download aria-hidden className="size-4" />
|
|
1030
|
+
{t("downloadSvg")}
|
|
1031
|
+
</Button>
|
|
1032
|
+
) : null}
|
|
963
1033
|
{downloadSelectionMode ? (
|
|
964
1034
|
<Button
|
|
965
1035
|
type="button"
|
|
@@ -1057,7 +1127,13 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1057
1127
|
</div>
|
|
1058
1128
|
</div>
|
|
1059
1129
|
) : null}
|
|
1060
|
-
<div
|
|
1130
|
+
<div
|
|
1131
|
+
className={`grid items-start gap-4 ${
|
|
1132
|
+
resultSlots.length === 1
|
|
1133
|
+
? "grid-cols-1"
|
|
1134
|
+
: "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
|
1135
|
+
}`}
|
|
1136
|
+
>
|
|
1061
1137
|
{resultSlots.map(({ asset, format, slot }) => {
|
|
1062
1138
|
if (!asset) {
|
|
1063
1139
|
const dimensions = dimensionsForAssetFormat(format);
|
|
@@ -1066,11 +1142,8 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1066
1142
|
key={`pending-${slot}`}
|
|
1067
1143
|
aria-label={t("pendingImageLoading", { index: slot })}
|
|
1068
1144
|
role="status"
|
|
1069
|
-
style={{
|
|
1070
|
-
|
|
1071
|
-
width: widthForAssetFormat(format),
|
|
1072
|
-
}}
|
|
1073
|
-
className="flex max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)]"
|
|
1145
|
+
style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
|
|
1146
|
+
className={`flex w-full max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)] ${gridClassForAssetFormat(format, resultSlots.length === 1)}`}
|
|
1074
1147
|
>
|
|
1075
1148
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[#F4F4F4] text-[#111111]">
|
|
1076
1149
|
<Loader2 aria-hidden className="size-4 animate-spin" />
|
|
@@ -1092,8 +1165,7 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1092
1165
|
return (
|
|
1093
1166
|
<div
|
|
1094
1167
|
key={asset.id}
|
|
1095
|
-
|
|
1096
|
-
className="relative max-w-full"
|
|
1168
|
+
className={`relative w-full max-w-full ${gridClassForAssetFormat(asset.format, resultSlots.length === 1)}`}
|
|
1097
1169
|
>
|
|
1098
1170
|
<button
|
|
1099
1171
|
type="button"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import type { AppStoreScreenshotAsset } from "../../../../lib/tools/app-store-screenshot-generator-schema";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildGeneratedAssetSlots,
|
|
8
|
+
gridClassForAssetFormat,
|
|
9
|
+
} from "./asset-slots";
|
|
10
|
+
|
|
11
|
+
function asset(overrides: Partial<AppStoreScreenshotAsset>): AppStoreScreenshotAsset {
|
|
12
|
+
return {
|
|
13
|
+
id: "asset-1",
|
|
14
|
+
platform: "google",
|
|
15
|
+
format: "googlePhone",
|
|
16
|
+
width: 1080,
|
|
17
|
+
height: 1920,
|
|
18
|
+
imageUrl: "https://example.com/generated.png",
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("app store screenshot result slots", () => {
|
|
24
|
+
it("keeps completed and pending Google assets in the same ordered slot model", () => {
|
|
25
|
+
const slots = buildGeneratedAssetSlots({
|
|
26
|
+
assets: [asset({ id: "phone-2", slot: 2 })],
|
|
27
|
+
expectedCount: 3,
|
|
28
|
+
includePending: true,
|
|
29
|
+
platform: "google",
|
|
30
|
+
totalCount: 3,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
slots.map(({ asset: result, format, slot }) => ({ id: result?.id, format, slot })),
|
|
35
|
+
[
|
|
36
|
+
{ id: undefined, format: "featureGraphic", slot: 1 },
|
|
37
|
+
{ id: "phone-2", format: "googlePhone", slot: 2 },
|
|
38
|
+
{ id: undefined, format: "googlePhone", slot: 3 },
|
|
39
|
+
],
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("lets feature graphics span the responsive result grid", () => {
|
|
44
|
+
assert.equal(gridClassForAssetFormat("featureGraphic"), "sm:col-span-2 xl:col-span-3");
|
|
45
|
+
assert.equal(gridClassForAssetFormat("iphone"), "");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("centers a sole result at an inspectable width without creating implicit grid columns", () => {
|
|
49
|
+
assert.equal(gridClassForAssetFormat("featureGraphic", true), "mx-auto max-w-[340px]");
|
|
50
|
+
assert.equal(gridClassForAssetFormat("iphone", true), "mx-auto max-w-[340px]");
|
|
51
|
+
});
|
|
52
|
+
});
|