@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.
- 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 +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/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.6",
|
|
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
|
@@ -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(
|
|
@@ -179,8 +201,16 @@ export function AppStoreScreenshotGenerator() {
|
|
|
179
201
|
return;
|
|
180
202
|
}
|
|
181
203
|
|
|
204
|
+
await runGeneration(parsed.data);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function runGeneration(
|
|
208
|
+
input: AppStoreScreenshotGeneratorInput,
|
|
209
|
+
recoveredTaskId?: string,
|
|
210
|
+
recoveredStartedAtMs?: number,
|
|
211
|
+
) {
|
|
182
212
|
try {
|
|
183
|
-
setDraft(
|
|
213
|
+
setDraft(input);
|
|
184
214
|
setBusy(true);
|
|
185
215
|
setOutput(null);
|
|
186
216
|
setSelectedId(null);
|
|
@@ -190,16 +220,16 @@ export function AppStoreScreenshotGenerator() {
|
|
|
190
220
|
setGenerationProgress({
|
|
191
221
|
phase: "submitting",
|
|
192
222
|
completedCount: 0,
|
|
193
|
-
totalCount: expectedAssetCount(
|
|
223
|
+
totalCount: expectedAssetCount(input),
|
|
194
224
|
failedCount: 0,
|
|
195
|
-
estimatedRemainingSeconds: initialEstimatedSeconds(
|
|
225
|
+
estimatedRemainingSeconds: initialEstimatedSeconds(input),
|
|
196
226
|
});
|
|
197
227
|
requestAnimationFrame(() => {
|
|
198
228
|
resultRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
199
229
|
});
|
|
200
230
|
|
|
201
231
|
const generated = await generateStoreAssets(
|
|
202
|
-
|
|
232
|
+
input,
|
|
203
233
|
locale,
|
|
204
234
|
(progress) => {
|
|
205
235
|
const parsedProgressOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(
|
|
@@ -226,11 +256,18 @@ export function AppStoreScreenshotGenerator() {
|
|
|
226
256
|
updateGenerationProgress({
|
|
227
257
|
phase: "planning",
|
|
228
258
|
completedCount: 0,
|
|
229
|
-
totalCount: expectedAssetCount(
|
|
259
|
+
totalCount: expectedAssetCount(input),
|
|
230
260
|
failedCount: 0,
|
|
231
|
-
estimatedRemainingSeconds: initialEstimatedSeconds(
|
|
261
|
+
estimatedRemainingSeconds: initialEstimatedSeconds(input),
|
|
232
262
|
});
|
|
233
263
|
},
|
|
264
|
+
{
|
|
265
|
+
taskId: recoveredTaskId,
|
|
266
|
+
startedAtMs: recoveredStartedAtMs,
|
|
267
|
+
onTaskSubmitted: (taskId, startedAtMs) => {
|
|
268
|
+
persistToolTaskRecovery(RECOVERY_SLUG, taskId, input, startedAtMs);
|
|
269
|
+
},
|
|
270
|
+
},
|
|
234
271
|
);
|
|
235
272
|
const parsedOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(generated.output);
|
|
236
273
|
|
|
@@ -286,6 +323,7 @@ export function AppStoreScreenshotGenerator() {
|
|
|
286
323
|
}
|
|
287
324
|
|
|
288
325
|
function reset() {
|
|
326
|
+
clearToolTaskRecovery(RECOVERY_SLUG);
|
|
289
327
|
setOutput(null);
|
|
290
328
|
setSelectedId(null);
|
|
291
329
|
setLightboxIndex(null);
|
|
@@ -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
|
+
});
|