@tomako/tools-runtime 0.1.8 → 0.1.9
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 +1 -1
- package/src/components/tools/engine/use-legacy-llm-task.ts +23 -1
- package/src/components/tools/engine/use-task-activity.ts +37 -0
- package/src/components/tools/workspace/tool-loading-panel.tsx +16 -2
- package/src/features/tools/cold-start-channel-selector/widget/cold-start-channel-selector.tsx +51 -7
- package/src/features/tools/cold-start-channel-selector/widget/preview-panels.tsx +22 -2
- package/src/features/tools/cold-start-channel-selector/widget/types.ts +8 -2
- package/src/features/tools/product-name-generator/widget/product-name-generator.tsx +64 -33
- package/src/features/tools/product-name-generator/widget/types.ts +8 -1
- package/src/features/tools/startup-idea-research/startup-idea-research.container.tsx +4 -4
- 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 -1
- package/src/i18n/messages/en/tools/product-name-generator.ts +7 -2
- package/src/i18n/messages/en/tools/startup-idea-research.ts +5 -5
- package/src/i18n/messages/zh/tools/app-store-screenshot-generator.ts +2 -2
- package/src/i18n/messages/zh/tools/cold-start-channel-selector.ts +4 -1
- package/src/i18n/messages/zh/tools/product-name-generator.ts +7 -2
- package/src/i18n/messages/zh/tools/startup-idea-research.ts +5 -5
- package/src/i18n/messages/zh-tw/tools/cold-start-channel-selector.ts +4 -1
- package/src/i18n/messages/zh-tw/tools/product-name-generator.ts +7 -2
- package/src/i18n/messages/zh-tw/tools/startup-idea-research.ts +5 -5
package/package.json
CHANGED
|
@@ -9,12 +9,33 @@ import {
|
|
|
9
9
|
|
|
10
10
|
export type LegacyTaskErrorCategory = "task" | "schema" | "timeout";
|
|
11
11
|
|
|
12
|
+
export type LegacyTaskProgress = {
|
|
13
|
+
stage?: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
updatedAt?: string;
|
|
16
|
+
state?: string;
|
|
17
|
+
completedCount?: number;
|
|
18
|
+
totalCount?: number;
|
|
19
|
+
tasks?: Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
status: "pending" | "in_progress" | "completed" | "failed";
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
|
|
12
26
|
export type LegacyTaskWatchHandlers<TResult> = {
|
|
13
27
|
onStatus: (status: string) => void;
|
|
28
|
+
onProgress?: (progress: LegacyTaskProgress) => void;
|
|
14
29
|
onSuccess: (data: TResult) => void;
|
|
15
30
|
onError: (category: LegacyTaskErrorCategory) => void;
|
|
16
31
|
};
|
|
17
32
|
|
|
33
|
+
function legacyErrorCategory(category: string): LegacyTaskErrorCategory {
|
|
34
|
+
if (category === "timeout") return "timeout";
|
|
35
|
+
if (category === "schema" || category === "invalid") return "schema";
|
|
36
|
+
return "task";
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
/**
|
|
19
40
|
* Legacy custom widget 任务监听:封装 watchLlmTask(SSE + 自适应轮询),
|
|
20
41
|
* 替代各 widget 内手写的 setInterval / 重复 SSE 逻辑。
|
|
@@ -53,8 +74,9 @@ export function useLegacyLlmTaskWatch<TResult>() {
|
|
|
53
74
|
pollOnce,
|
|
54
75
|
handlers: {
|
|
55
76
|
onStatus: handlers.onStatus,
|
|
77
|
+
onProgress: handlers.onProgress,
|
|
56
78
|
onSuccess: (data: TResult) => handlers.onSuccess(data as TResult),
|
|
57
|
-
onError: handlers.onError,
|
|
79
|
+
onError: (category: string) => handlers.onError(legacyErrorCategory(category)),
|
|
58
80
|
},
|
|
59
81
|
isCancelled: () => cancelledRef.current,
|
|
60
82
|
pollIntervalMs: options?.pollIntervalMs,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
function formatClock(elapsedMs: number): string {
|
|
6
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
7
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
8
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
9
|
+
const seconds = totalSeconds % 60;
|
|
10
|
+
return hours > 0
|
|
11
|
+
? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
|
12
|
+
: `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
13
|
+
}
|
|
14
|
+
function timestampMs(value: string | undefined): number | null {
|
|
15
|
+
if (!value) return null;
|
|
16
|
+
const parsed = Date.parse(value);
|
|
17
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Honest task timing: elapsed wall time and age of the last real progress update. */
|
|
21
|
+
export function useTaskActivity(startedAtMs: number, progressUpdatedAt?: string) {
|
|
22
|
+
const [now, setNow] = useState(() => Date.now());
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
|
26
|
+
return () => window.clearInterval(timer);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
return useMemo(() => {
|
|
30
|
+
const lastActivityAt = timestampMs(progressUpdatedAt);
|
|
31
|
+
return {
|
|
32
|
+
elapsed: formatClock(now - startedAtMs),
|
|
33
|
+
lastActivityAgo:
|
|
34
|
+
lastActivityAt === null ? null : formatClock(now - lastActivityAt),
|
|
35
|
+
};
|
|
36
|
+
}, [now, progressUpdatedAt, startedAtMs]);
|
|
37
|
+
}
|
|
@@ -226,20 +226,34 @@ export function ToolLoadingPanel({
|
|
|
226
226
|
steps,
|
|
227
227
|
tipLabel,
|
|
228
228
|
tip,
|
|
229
|
+
activity,
|
|
229
230
|
}: {
|
|
230
231
|
label: string;
|
|
231
|
-
progress
|
|
232
|
+
progress?: number;
|
|
232
233
|
steps: readonly ToolLoadingStep[];
|
|
233
234
|
tipLabel?: ReactNode;
|
|
234
235
|
tip?: ReactNode;
|
|
236
|
+
activity?: ReactNode;
|
|
235
237
|
}) {
|
|
236
238
|
return (
|
|
237
239
|
<div className="flex min-h-0 flex-1 items-center justify-center px-5 py-8 text-center md:px-8">
|
|
238
240
|
<div className="w-full max-w-[34rem]">
|
|
239
241
|
<TomatoParticleLoader label={label} />
|
|
240
242
|
<div className="mx-auto mt-6 w-full max-w-[18rem]">
|
|
241
|
-
<ProgressBar
|
|
243
|
+
<ProgressBar
|
|
244
|
+
value={progress ?? 100}
|
|
245
|
+
className={cn(
|
|
246
|
+
"h-1.5 bg-[#EEF1F5] [&_[data-slot=progress-indicator]]:bg-[#111111]",
|
|
247
|
+
progress === undefined &&
|
|
248
|
+
"[&_[data-slot=progress-indicator]]:animate-pulse [&_[data-slot=progress-indicator]]:opacity-60",
|
|
249
|
+
)}
|
|
250
|
+
/>
|
|
242
251
|
</div>
|
|
252
|
+
{activity ? (
|
|
253
|
+
<div className="mx-auto mt-3 w-full max-w-[18rem] text-xs leading-5 text-[#777777]">
|
|
254
|
+
{activity}
|
|
255
|
+
</div>
|
|
256
|
+
) : null}
|
|
243
257
|
<div className="mx-auto mt-7 grid w-full max-w-[18rem] gap-2 text-left">
|
|
244
258
|
{steps.map((step, index) => {
|
|
245
259
|
const isComplete = step.status === "complete";
|
package/src/features/tools/cold-start-channel-selector/widget/cold-start-channel-selector.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
ToolActionBar,
|
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
submitColdStartChannelSelector,
|
|
28
28
|
} from "../../../../host/client";
|
|
29
29
|
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
useLegacyLlmTaskWatch,
|
|
32
|
+
type LegacyTaskProgress,
|
|
33
|
+
} from "../../../../components/tools/engine/use-legacy-llm-task";
|
|
31
34
|
import {
|
|
32
35
|
clearToolTaskRecovery,
|
|
33
36
|
persistToolTaskRecovery,
|
|
@@ -66,7 +69,22 @@ export function ColdStartChannelSelector() {
|
|
|
66
69
|
const stageOptions = t.raw("stageOptions") as Option[];
|
|
67
70
|
const weeklyHoursOptions = t.raw("weeklyHoursOptions") as Option[];
|
|
68
71
|
const budgetOptions = t.raw("budgetOptions") as Option[];
|
|
69
|
-
const loadingSteps =
|
|
72
|
+
const loadingSteps = useMemo<readonly LoadingStep[]>(() => {
|
|
73
|
+
const progress = state.phase === "running" ? state.progress : undefined;
|
|
74
|
+
if (progress?.tasks?.length) {
|
|
75
|
+
return progress.tasks.map((task) => ({
|
|
76
|
+
title: task.title,
|
|
77
|
+
status:
|
|
78
|
+
task.status === "in_progress"
|
|
79
|
+
? "active"
|
|
80
|
+
: task.status === "completed"
|
|
81
|
+
? "complete"
|
|
82
|
+
: "pending",
|
|
83
|
+
countdown: "",
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
return [{ title: progress?.detail ?? t("runningWaiting"), status: "active", countdown: "" }];
|
|
87
|
+
}, [state, t]);
|
|
70
88
|
|
|
71
89
|
const busy = state.phase === "submitting" || state.phase === "running";
|
|
72
90
|
|
|
@@ -137,18 +155,40 @@ export function ColdStartChannelSelector() {
|
|
|
137
155
|
if (result.status === "RESULT_INVALID") {
|
|
138
156
|
return { settled: true as const, kind: "error" as const, category: "schema" as const };
|
|
139
157
|
}
|
|
140
|
-
|
|
158
|
+
const progress: LegacyTaskProgress | undefined =
|
|
159
|
+
result.progressStage || result.progressDetail || result.progressUpdatedAt
|
|
160
|
+
? {
|
|
161
|
+
stage: result.progressStage,
|
|
162
|
+
detail: result.progressDetail,
|
|
163
|
+
updatedAt: result.progressUpdatedAt,
|
|
164
|
+
}
|
|
165
|
+
: undefined;
|
|
166
|
+
return { settled: false as const, status: result.status, progress };
|
|
141
167
|
}
|
|
142
168
|
|
|
143
169
|
function watchAnalysis(taskId: string, startedAtMs?: number) {
|
|
144
|
-
|
|
170
|
+
const startedAt = startedAtMs ?? Date.now();
|
|
171
|
+
setState({ phase: "running", status: startedAtMs ? "RESTORING" : "PENDING", startedAtMs: startedAt });
|
|
145
172
|
taskWatch.start(
|
|
146
173
|
taskId,
|
|
147
174
|
pollOnce,
|
|
148
175
|
{
|
|
149
176
|
onStatus: (status) => {
|
|
150
177
|
if (!cancelledRef.current && !settledRef.current) {
|
|
151
|
-
setState(
|
|
178
|
+
setState((current) =>
|
|
179
|
+
current.phase === "running"
|
|
180
|
+
? { ...current, status }
|
|
181
|
+
: { phase: "running", status, startedAtMs: startedAt },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
onProgress: (progress) => {
|
|
186
|
+
if (!cancelledRef.current && !settledRef.current) {
|
|
187
|
+
setState((current) =>
|
|
188
|
+
current.phase === "running"
|
|
189
|
+
? { ...current, progress }
|
|
190
|
+
: { phase: "running", status: "STREAMING", startedAtMs: startedAt, progress },
|
|
191
|
+
);
|
|
152
192
|
}
|
|
153
193
|
},
|
|
154
194
|
onSuccess: (data) => finishDone(data),
|
|
@@ -187,7 +227,7 @@ export function ColdStartChannelSelector() {
|
|
|
187
227
|
cancelledRef.current = false;
|
|
188
228
|
settledRef.current = false;
|
|
189
229
|
cleanup();
|
|
190
|
-
setState({ phase: "submitting" });
|
|
230
|
+
setState({ phase: "submitting", startedAtMs: Date.now() });
|
|
191
231
|
|
|
192
232
|
let taskId: string;
|
|
193
233
|
try {
|
|
@@ -424,10 +464,14 @@ export function ColdStartChannelSelector() {
|
|
|
424
464
|
<div className="min-h-0 flex-1 overflow-y-auto pt-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
425
465
|
{state.phase === "submitting" || state.phase === "running" ? (
|
|
426
466
|
<RunningPanel
|
|
467
|
+
startedAtMs={state.startedAtMs}
|
|
468
|
+
progress={state.phase === "running" ? state.progress : undefined}
|
|
427
469
|
statusLabel={t("runningStatusLabel")}
|
|
428
470
|
steps={loadingSteps}
|
|
429
471
|
tip={t("loadingTip")}
|
|
430
472
|
tipLabel={t("runningTipLabel")}
|
|
473
|
+
elapsedLabel={(time) => t("runningElapsed", { time })}
|
|
474
|
+
lastActivityLabel={(time) => t("runningLastActivity", { time })}
|
|
431
475
|
/>
|
|
432
476
|
) : state.phase === "error" ? (
|
|
433
477
|
<div className="flex min-h-full items-center">
|
|
@@ -6,6 +6,8 @@ import { useEffect, useRef } from "react";
|
|
|
6
6
|
import { ProgressBar } from "@tomako/ui/progress";
|
|
7
7
|
import { cn } from "@tomako/ui/cn";
|
|
8
8
|
|
|
9
|
+
import type { LegacyTaskProgress } from "../../../../components/tools/engine/use-legacy-llm-task";
|
|
10
|
+
import { useTaskActivity } from "../../../../components/tools/engine/use-task-activity";
|
|
9
11
|
import { tomatoParticlePalette, type TomatoParticle } from "./constants";
|
|
10
12
|
import {
|
|
11
13
|
clamp01,
|
|
@@ -181,22 +183,40 @@ export function RunningPanel({
|
|
|
181
183
|
tip,
|
|
182
184
|
tipLabel,
|
|
183
185
|
statusLabel,
|
|
186
|
+
startedAtMs,
|
|
187
|
+
progress,
|
|
188
|
+
elapsedLabel,
|
|
189
|
+
lastActivityLabel,
|
|
184
190
|
}: {
|
|
185
191
|
steps: readonly LoadingStep[];
|
|
186
192
|
tip: string;
|
|
187
193
|
tipLabel: string;
|
|
188
194
|
statusLabel: string;
|
|
195
|
+
startedAtMs: number;
|
|
196
|
+
progress?: LegacyTaskProgress;
|
|
197
|
+
elapsedLabel: (time: string) => string;
|
|
198
|
+
lastActivityLabel: (time: string) => string;
|
|
189
199
|
}) {
|
|
200
|
+
const activity = useTaskActivity(startedAtMs, progress?.updatedAt);
|
|
201
|
+
|
|
190
202
|
return (
|
|
191
203
|
<div className="flex min-h-full items-center justify-center px-2 py-4 text-center">
|
|
192
204
|
<div className="w-full max-w-[34rem]">
|
|
193
205
|
<TomatoParticleLoader statusLabel={statusLabel} />
|
|
194
206
|
<div className="mx-auto mt-8 w-full max-w-[16rem]">
|
|
195
207
|
<ProgressBar
|
|
196
|
-
value={
|
|
197
|
-
className=
|
|
208
|
+
value={100}
|
|
209
|
+
className={cn(
|
|
210
|
+
"h-1.5 bg-[#EEF1F5] [&_[data-slot=progress-indicator]]:bg-[#111111]",
|
|
211
|
+
"[&_[data-slot=progress-indicator]]:animate-pulse [&_[data-slot=progress-indicator]]:opacity-60",
|
|
212
|
+
)}
|
|
198
213
|
/>
|
|
199
214
|
</div>
|
|
215
|
+
<div className="mx-auto mt-3 w-full max-w-[18rem] text-xs leading-5 text-[#777777]">
|
|
216
|
+
<p>{elapsedLabel(activity.elapsed)}</p>
|
|
217
|
+
{progress?.detail ? <p className="mt-1 text-[#555555]">{progress.detail}</p> : null}
|
|
218
|
+
{activity.lastActivityAgo ? <p>{lastActivityLabel(activity.lastActivityAgo)}</p> : null}
|
|
219
|
+
</div>
|
|
200
220
|
<div className="mx-auto mt-8 grid w-full max-w-[18rem] gap-2.5 text-left">
|
|
201
221
|
{steps.map((step) => {
|
|
202
222
|
const isComplete = step.status === "complete";
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { useTranslations } from "../../../../i18n/client";
|
|
2
2
|
import type { ColdStartChannelSelectorResult } from "../../../../lib/tools/cold-start-channel-selector-schema";
|
|
3
|
+
import type { LegacyTaskProgress } from "../../../../components/tools/engine/use-legacy-llm-task";
|
|
3
4
|
|
|
4
5
|
export type ErrorCategory = "submit" | "task" | "timeout" | "schema" | "validation";
|
|
5
6
|
|
|
6
7
|
export type WidgetState =
|
|
7
8
|
| { phase: "idle" }
|
|
8
|
-
| { phase: "submitting" }
|
|
9
|
-
| {
|
|
9
|
+
| { phase: "submitting"; startedAtMs: number }
|
|
10
|
+
| {
|
|
11
|
+
phase: "running";
|
|
12
|
+
status: string;
|
|
13
|
+
startedAtMs: number;
|
|
14
|
+
progress?: LegacyTaskProgress;
|
|
15
|
+
}
|
|
10
16
|
| { phase: "done"; data: ColdStartChannelSelectorResult }
|
|
11
17
|
| { phase: "error"; category: ErrorCategory };
|
|
12
18
|
|
|
@@ -38,7 +38,11 @@ import {
|
|
|
38
38
|
import { ChevronDown, Lightbulb } from "lucide-react";
|
|
39
39
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
|
40
40
|
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
useLegacyLlmTaskWatch,
|
|
43
|
+
type LegacyTaskProgress,
|
|
44
|
+
} from "../../../../components/tools/engine/use-legacy-llm-task";
|
|
45
|
+
import { useTaskActivity } from "../../../../components/tools/engine/use-task-activity";
|
|
42
46
|
import {
|
|
43
47
|
clearToolTaskRecovery,
|
|
44
48
|
persistToolTaskRecovery,
|
|
@@ -72,11 +76,7 @@ import {
|
|
|
72
76
|
defaultForm,
|
|
73
77
|
} from "./constants";
|
|
74
78
|
import { MultiSelectMenu, StyleSelect } from "./form-controls";
|
|
75
|
-
import {
|
|
76
|
-
screenLoadingProgress,
|
|
77
|
-
screenLoadingStepIndex,
|
|
78
|
-
wait,
|
|
79
|
-
} from "./helpers";
|
|
79
|
+
import { wait } from "./helpers";
|
|
80
80
|
import type {
|
|
81
81
|
DetailState,
|
|
82
82
|
FieldErrors,
|
|
@@ -103,6 +103,7 @@ export function ProductNameGenerator() {
|
|
|
103
103
|
const [copied, setCopied] = useState<string | null>(null);
|
|
104
104
|
const [revealedCount, setRevealedCount] = useState(0);
|
|
105
105
|
const activeScreenTaskIdRef = useRef<string | null>(null);
|
|
106
|
+
const idleClockStartedAtRef = useRef(Date.now());
|
|
106
107
|
const resultRef = useRef<HTMLDivElement | null>(null);
|
|
107
108
|
const descriptionRef = useRef<HTMLTextAreaElement | null>(null);
|
|
108
109
|
const marketsRef = useRef<HTMLButtonElement | null>(null);
|
|
@@ -148,30 +149,31 @@ export function ProductNameGenerator() {
|
|
|
148
149
|
const result = screenState.phase === "done" ? screenState.data : null;
|
|
149
150
|
const visibleNames = result?.names.slice(0, revealedCount) ?? [];
|
|
150
151
|
const canSubmit = screenState.phase !== "loading";
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
);
|
|
152
|
+
const screenProgress = screenState.phase === "loading" ? screenState.progress : undefined;
|
|
153
|
+
const screenStartedAtMs =
|
|
154
|
+
screenState.phase === "loading" ? screenState.startedAtMs : idleClockStartedAtRef.current;
|
|
155
|
+
const taskActivity = useTaskActivity(screenStartedAtMs, screenProgress?.updatedAt);
|
|
155
156
|
const loadingSteps = useMemo<readonly ToolLoadingStep[]>(() => {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
:
|
|
160
|
-
|
|
161
|
-
return loadingStepTitles.map((title, index) => ({
|
|
162
|
-
title,
|
|
163
|
-
status:
|
|
164
|
-
index < activeStep
|
|
165
|
-
? "complete"
|
|
166
|
-
: index === activeStep
|
|
157
|
+
if (screenProgress?.tasks?.length) {
|
|
158
|
+
return screenProgress.tasks.slice(0, SCREEN_LOADING_STEP_COUNT).map((task) => ({
|
|
159
|
+
title: task.title,
|
|
160
|
+
status:
|
|
161
|
+
task.status === "in_progress"
|
|
167
162
|
? "active"
|
|
168
|
-
: "
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
163
|
+
: task.status === "completed"
|
|
164
|
+
? "complete"
|
|
165
|
+
: "pending",
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
const status = screenState.phase === "loading" ? screenState.status : "PENDING";
|
|
169
|
+
const title =
|
|
170
|
+
status === "AWAITING_RESULT" || status === "AWAITING_INPUT" || status === "SUCCEEDED"
|
|
171
|
+
? t("loadingStateFinalizing")
|
|
172
|
+
: status === "PENDING" || status === "RESTORING"
|
|
173
|
+
? t("loadingStatePending")
|
|
174
|
+
: t("loadingStateRunning");
|
|
175
|
+
return [{ title: screenProgress?.detail ?? title, status: "active" }];
|
|
176
|
+
}, [screenProgress, screenState, t]);
|
|
175
177
|
|
|
176
178
|
useEffect(() => {
|
|
177
179
|
if (!result) return;
|
|
@@ -334,9 +336,18 @@ export function ProductNameGenerator() {
|
|
|
334
336
|
category: task.status === "RESULT_INVALID" ? ("schema" as const) : ("task" as const),
|
|
335
337
|
};
|
|
336
338
|
}
|
|
337
|
-
|
|
339
|
+
const progress: LegacyTaskProgress | undefined =
|
|
340
|
+
task.progressStage || task.progressDetail || task.progressUpdatedAt
|
|
341
|
+
? {
|
|
342
|
+
stage: task.progressStage,
|
|
343
|
+
detail: task.progressDetail,
|
|
344
|
+
updatedAt: task.progressUpdatedAt,
|
|
345
|
+
}
|
|
346
|
+
: undefined;
|
|
338
347
|
return {
|
|
339
348
|
settled: false as const,
|
|
349
|
+
status: task.status,
|
|
350
|
+
progress,
|
|
340
351
|
pendingWriteback:
|
|
341
352
|
task.status === "AWAITING_INPUT" || task.status === "SUCCEEDED",
|
|
342
353
|
};
|
|
@@ -347,14 +358,26 @@ export function ProductNameGenerator() {
|
|
|
347
358
|
status: string,
|
|
348
359
|
startedAtMs?: number,
|
|
349
360
|
) {
|
|
361
|
+
const startedAt = startedAtMs ?? Date.now();
|
|
350
362
|
activeScreenTaskIdRef.current = taskId;
|
|
351
|
-
setScreenState({ phase: "loading", status, taskId });
|
|
363
|
+
setScreenState({ phase: "loading", status, taskId, startedAtMs: startedAt });
|
|
352
364
|
screenTaskWatch.start(
|
|
353
365
|
taskId,
|
|
354
366
|
pollScreenOnce,
|
|
355
367
|
{
|
|
356
368
|
onStatus: (nextStatus) => {
|
|
357
|
-
setScreenState(
|
|
369
|
+
setScreenState((current) =>
|
|
370
|
+
current.phase === "loading"
|
|
371
|
+
? { ...current, status: nextStatus, taskId }
|
|
372
|
+
: { phase: "loading", status: nextStatus, taskId, startedAtMs: startedAt },
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
onProgress: (progress) => {
|
|
376
|
+
setScreenState((current) =>
|
|
377
|
+
current.phase === "loading"
|
|
378
|
+
? { ...current, progress, taskId }
|
|
379
|
+
: { phase: "loading", status: "STREAMING", taskId, startedAtMs: startedAt, progress },
|
|
380
|
+
);
|
|
358
381
|
},
|
|
359
382
|
onSuccess: (data) => {
|
|
360
383
|
setScreenState({ phase: "done", data });
|
|
@@ -384,7 +407,7 @@ export function ProductNameGenerator() {
|
|
|
384
407
|
setCopied(null);
|
|
385
408
|
setRevealedCount(0);
|
|
386
409
|
setDetailStates({});
|
|
387
|
-
setScreenState({ phase: "loading", status: "PENDING" });
|
|
410
|
+
setScreenState({ phase: "loading", status: "PENDING", startedAtMs: Date.now() });
|
|
388
411
|
scrollToResult();
|
|
389
412
|
|
|
390
413
|
try {
|
|
@@ -721,10 +744,18 @@ export function ProductNameGenerator() {
|
|
|
721
744
|
{screenState.phase === "loading" ? (
|
|
722
745
|
<ToolLoadingPanel
|
|
723
746
|
label={t("loadingTitle")}
|
|
724
|
-
progress={screenLoadingProgress(screenState.status)}
|
|
725
747
|
steps={loadingSteps}
|
|
726
748
|
tipLabel={t("loadingStatus")}
|
|
727
749
|
tip={t("loadingHint")}
|
|
750
|
+
activity={
|
|
751
|
+
<>
|
|
752
|
+
<p>{t("loadingElapsed", { time: taskActivity.elapsed })}</p>
|
|
753
|
+
{screenProgress?.detail ? <p className="mt-1 text-[#555555]">{screenProgress.detail}</p> : null}
|
|
754
|
+
{taskActivity.lastActivityAgo ? (
|
|
755
|
+
<p>{t("loadingLastActivity", { time: taskActivity.lastActivityAgo })}</p>
|
|
756
|
+
) : null}
|
|
757
|
+
</>
|
|
758
|
+
}
|
|
728
759
|
/>
|
|
729
760
|
) : null}
|
|
730
761
|
|
|
@@ -5,10 +5,17 @@ import type {
|
|
|
5
5
|
ProductNameStyle,
|
|
6
6
|
ProductNameTld
|
|
7
7
|
} from "../../../../lib/tools/product-name-generator-schema";
|
|
8
|
+
import type { LegacyTaskProgress } from "../../../../components/tools/engine/use-legacy-llm-task";
|
|
8
9
|
|
|
9
10
|
export type ScreenState =
|
|
10
11
|
| { phase: "idle" }
|
|
11
|
-
| {
|
|
12
|
+
| {
|
|
13
|
+
phase: "loading";
|
|
14
|
+
status: string;
|
|
15
|
+
taskId?: string;
|
|
16
|
+
startedAtMs: number;
|
|
17
|
+
progress?: LegacyTaskProgress;
|
|
18
|
+
}
|
|
12
19
|
| { phase: "done"; data: ProductNameScreenResult }
|
|
13
20
|
| { phase: "error"; message: string };
|
|
14
21
|
|
|
@@ -46,7 +46,7 @@ export async function StartupIdeaResearchContainer({ locale, spec }: ToolPagePro
|
|
|
46
46
|
<div className="grid gap-8 xl:grid-cols-[1fr_0.42fr] xl:items-start">
|
|
47
47
|
<StartupIdeaResearch />
|
|
48
48
|
<ToolImageVisual
|
|
49
|
-
src="/tools/startup-idea-research-
|
|
49
|
+
src="/tools/startup-idea-research-workflow.webp"
|
|
50
50
|
alt={t("visualAlt.workspace")}
|
|
51
51
|
/>
|
|
52
52
|
</div>
|
|
@@ -72,11 +72,11 @@ export async function StartupIdeaResearchContainer({ locale, spec }: ToolPagePro
|
|
|
72
72
|
<ToolDividedRows items={standards} />
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
75
|
-
<ToolImageVisual src="/tools/startup-idea-research-
|
|
75
|
+
<ToolImageVisual src="/tools/startup-idea-research-evidence.webp" alt={t("visualAlt.value")} />
|
|
76
76
|
</section>
|
|
77
77
|
|
|
78
78
|
<section className="grid gap-8 border-t border-[#E8E4DB] py-12 md:grid-cols-[1.05fr_0.95fr] md:items-center md:py-16">
|
|
79
|
-
<ToolImageVisual src="/tools/startup-idea-research-
|
|
79
|
+
<ToolImageVisual src="/tools/startup-idea-research-experiments.webp" alt={t("visualAlt.support")} />
|
|
80
80
|
<div>
|
|
81
81
|
<ToolSectionEyebrow>{t("resultKicker")}</ToolSectionEyebrow>
|
|
82
82
|
<h2 className="mt-3 text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
|
|
@@ -98,7 +98,7 @@ export async function StartupIdeaResearchContainer({ locale, spec }: ToolPagePro
|
|
|
98
98
|
<ToolDividedFaqList items={faqItems} />
|
|
99
99
|
</div>
|
|
100
100
|
</div>
|
|
101
|
-
<ToolImageVisual src="/tools/startup-idea-research-
|
|
101
|
+
<ToolImageVisual src="/tools/startup-idea-research-recruiting.webp" alt={t("visualAlt.useCase")} />
|
|
102
102
|
</section>
|
|
103
103
|
|
|
104
104
|
<ToolCta
|
|
@@ -41,7 +41,7 @@ const appStoreScreenshotGenerator = {
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
title: "Recoverable failure hints",
|
|
44
|
-
body: "Missing
|
|
44
|
+
body: "Missing screenshots, unsupported file types, public URL fetch failures, and export failures return actionable messages instead of empty creatives.",
|
|
45
45
|
},
|
|
46
46
|
],
|
|
47
47
|
comparisonHeading: "What a good app store screenshot generator should do",
|
|
@@ -80,7 +80,7 @@ const appStoreScreenshotGenerator = {
|
|
|
80
80
|
"Apple App Store and Google Play directions are supported, including complete screenshot sets, portrait screenshots, and feature graphics.",
|
|
81
81
|
"This version uses an online image generation workflow to submit live image tasks, but it does not connect to App Store Connect or Google Play Console.",
|
|
82
82
|
"If a screenshot includes test accounts, real users, private data, or unauthorized brands, redact it before use.",
|
|
83
|
-
"If no result appears,
|
|
83
|
+
"If no result appears, confirm that a PNG/JPG/WebP screenshot is uploaded and browser file reading is allowed. If you added a product URL, confirm that its public page is reachable.",
|
|
84
84
|
"Before real submission, review the latest platform dimensions, review policies, trademark or copyright permissions, privacy content, copy claims, and readability on target devices.",
|
|
85
85
|
],
|
|
86
86
|
resultGuidanceHeading: "How to use the downloaded listing images",
|
|
@@ -318,7 +318,7 @@ const coldStartChannelSelector = {
|
|
|
318
318
|
],
|
|
319
319
|
runningTitle: "Building your channel strategy",
|
|
320
320
|
runningHint:
|
|
321
|
-
"We are comparing your product context and constraints
|
|
321
|
+
"We are comparing your product context and constraints. The page shows actual elapsed time and any real progress available.",
|
|
322
322
|
loadingSteps: [
|
|
323
323
|
{ status: "complete", title: "Reading product context", countdown: "" },
|
|
324
324
|
{ status: "active", title: "Comparing channel options", countdown: "" },
|
|
@@ -327,6 +327,9 @@ const coldStartChannelSelector = {
|
|
|
327
327
|
loadingTip:
|
|
328
328
|
"Cold-start channels usually work better when one low-cost community, content, or partner path is validated before heavier ads or long-term SEO.",
|
|
329
329
|
runningStatusLabel: "Generating your channel strategy",
|
|
330
|
+
runningWaiting: "Task submitted; waiting for the next real progress update",
|
|
331
|
+
runningElapsed: "Running for {time}",
|
|
332
|
+
runningLastActivity: "Last progress update {time} ago",
|
|
330
333
|
runningTipLabel: "Tip",
|
|
331
334
|
reportTitle: "Customer acquisition channel strategy",
|
|
332
335
|
copyReport: "Copy report",
|
|
@@ -281,9 +281,14 @@ const productNameGenerator = {
|
|
|
281
281
|
"Workspace side visual showing rough naming inputs becoming three candidate directions and domain-extension signals.",
|
|
282
282
|
loadingTitle: "Generating and screening",
|
|
283
283
|
loadingStatus: "Preparing the name shortlist",
|
|
284
|
-
loadingCountdown: "
|
|
284
|
+
loadingCountdown: "updates live",
|
|
285
|
+
loadingStatePending: "Task submitted; waiting for the Agent to start",
|
|
286
|
+
loadingStateRunning: "The Agent is working on the task",
|
|
287
|
+
loadingStateFinalizing: "Analysis finished; waiting for the structured result",
|
|
288
|
+
loadingElapsed: "Running for {time}",
|
|
289
|
+
loadingLastActivity: "Last progress update {time} ago",
|
|
285
290
|
loadingHint:
|
|
286
|
-
"Generating name candidates from your product idea and running basic pronunciation, length, and domain-extension screening.
|
|
291
|
+
"Generating name candidates from your product idea and running basic pronunciation, length, and domain-extension screening. Real progress appears automatically; the first pass does not perform full trademark clearance.",
|
|
287
292
|
loadingSteps: [
|
|
288
293
|
"Build naming strategy and candidates",
|
|
289
294
|
"Run pronunciation, length, and brand-fit screening",
|
|
@@ -107,11 +107,11 @@ const startupIdeaResearch = {
|
|
|
107
107
|
workspaceHint: "Describe the idea and target users to get public demand signals, assumptions to challenge, and the next validation test.",
|
|
108
108
|
workspaceBadge: "Startup idea validation",
|
|
109
109
|
visualAlt: {
|
|
110
|
-
hero: "Startup idea validation report preview
|
|
111
|
-
workspace: "
|
|
112
|
-
value: "
|
|
113
|
-
support: "
|
|
114
|
-
useCase: "
|
|
110
|
+
hero: "Startup idea validation report preview",
|
|
111
|
+
workspace: "Three-step flow from public evidence to a validation report and editable recruiting draft",
|
|
112
|
+
value: "Evidence dashboard for pain signals, user segments, and willingness-to-pay clues",
|
|
113
|
+
support: "Competitor evidence matrix and interview, landing-page, and payment validation experiments",
|
|
114
|
+
useCase: "Founder-review recruiting poster, post draft, and an empty state awaiting real responses",
|
|
115
115
|
},
|
|
116
116
|
valueKicker: "What you get",
|
|
117
117
|
valueTitle: "Not a pep talk: an evidence map for the riskiest assumptions",
|
|
@@ -41,7 +41,7 @@ const appStoreScreenshotGenerator = {
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
title: "失败原因提示",
|
|
44
|
-
body: "
|
|
44
|
+
body: "未上传截图、格式不支持、公开 URL 读取失败或导出失败时,结果区会给出可恢复提示,而不是生成空素材。",
|
|
45
45
|
},
|
|
46
46
|
],
|
|
47
47
|
comparisonHeading: "一个好的上架图生成器应该具备什么",
|
|
@@ -80,7 +80,7 @@ const appStoreScreenshotGenerator = {
|
|
|
80
80
|
"支持 Apple App Store 和 Google Play 两类平台方向,并提供完整截图套图、竖版截图或横版 feature graphic。",
|
|
81
81
|
"当前通过在线图像生成流程提交真实生图任务,但不连接 App Store Connect 或 Google Play Console 后台。",
|
|
82
82
|
"如果截图含测试账号、真实用户、隐私数据或未授权品牌,请先脱敏或不要上传。",
|
|
83
|
-
"
|
|
83
|
+
"如果没有结果,先检查是否已上传 PNG/JPG/WebP 截图、浏览器是否阻止本地文件读取;填写了产品 URL 时,再确认该公开页面可以访问。",
|
|
84
84
|
"正式上架前仍要复核最新尺寸、审核政策、商标/版权授权、隐私内容、文案承诺和不同设备上的可读性。",
|
|
85
85
|
],
|
|
86
86
|
resultGuidanceHeading: "下载后怎么使用这些上架图",
|
|
@@ -317,7 +317,7 @@ const coldStartChannelSelector = {
|
|
|
317
317
|
],
|
|
318
318
|
runningTitle: "正在生成你的渠道策略",
|
|
319
319
|
runningHint:
|
|
320
|
-
"
|
|
320
|
+
"正在结合产品信息和限制条件生成首轮渠道策略;页面会显示实际耗时和可用的真实进展。",
|
|
321
321
|
loadingSteps: [
|
|
322
322
|
{ status: "complete", title: "读取产品信息", countdown: "" },
|
|
323
323
|
{ status: "active", title: "比较渠道选项", countdown: "" },
|
|
@@ -326,6 +326,9 @@ const coldStartChannelSelector = {
|
|
|
326
326
|
loadingTip:
|
|
327
327
|
"冷启动通常先验证一个低成本社区、内容或伙伴渠道,再扩展到更重的广告和长期 SEO。",
|
|
328
328
|
runningStatusLabel: "正在生成渠道策略",
|
|
329
|
+
runningWaiting: "任务已提交,等待下一条真实进展",
|
|
330
|
+
runningElapsed: "已运行 {time}",
|
|
331
|
+
runningLastActivity: "最近进展更新于 {time} 前",
|
|
329
332
|
runningTipLabel: "提示",
|
|
330
333
|
reportTitle: "客户获取渠道策略",
|
|
331
334
|
copyReport: "复制报告",
|
|
@@ -281,9 +281,14 @@ const productNameGenerator = {
|
|
|
281
281
|
"工作台右侧视觉,展示粗略命名输入被整理成三个候选方向和域名后缀信号。",
|
|
282
282
|
loadingTitle: "正在生成与快筛",
|
|
283
283
|
loadingStatus: "正在准备候选名短名单",
|
|
284
|
-
loadingCountdown: "
|
|
284
|
+
loadingCountdown: "持续更新",
|
|
285
|
+
loadingStatePending: "任务已提交,正在等待 Agent 接手",
|
|
286
|
+
loadingStateRunning: "Agent 正在处理任务",
|
|
287
|
+
loadingStateFinalizing: "Agent 已完成分析,正在等待结构化结果",
|
|
288
|
+
loadingElapsed: "已运行 {time}",
|
|
289
|
+
loadingLastActivity: "最近进展更新于 {time} 前",
|
|
285
290
|
loadingHint:
|
|
286
|
-
"
|
|
291
|
+
"正在根据产品想法生成候选名,并做基础读音、长度和域名后缀快筛。真实进展会自动更新;首轮不会做完整商标清关。",
|
|
287
292
|
loadingSteps: [
|
|
288
293
|
"生成命名策略和候选名",
|
|
289
294
|
"运行读音、长度和基础品牌筛选",
|
|
@@ -103,11 +103,11 @@ const startupIdeaResearch = {
|
|
|
103
103
|
workspaceHint: "描述想法和目标用户,获得公开需求信号、待验证假设与下一步实验。",
|
|
104
104
|
workspaceBadge: "创业想法验证",
|
|
105
105
|
visualAlt: {
|
|
106
|
-
hero: "创业 idea
|
|
107
|
-
workspace: "
|
|
108
|
-
value: "
|
|
109
|
-
support: "
|
|
110
|
-
useCase: "
|
|
106
|
+
hero: "创业 idea 调研报告预览",
|
|
107
|
+
workspace: "公开证据被整理成验证报告与可编辑招募草案的三步流程",
|
|
108
|
+
value: "痛点信号、用户群体与付费意愿线索的证据分析面板",
|
|
109
|
+
support: "竞品证据矩阵与访谈、落地页、付费验证实验",
|
|
110
|
+
useCase: "待创始人审核的招募海报、短帖草案与真实反馈空状态",
|
|
111
111
|
},
|
|
112
112
|
valueKicker: "你会得到什么",
|
|
113
113
|
valueTitle: "不是替你拍脑袋,而是把 idea 拆成可验证证据",
|
|
@@ -317,7 +317,7 @@ const coldStartChannelSelector = {
|
|
|
317
317
|
],
|
|
318
318
|
runningTitle: "正在產生你的通路策略",
|
|
319
319
|
runningHint:
|
|
320
|
-
"
|
|
320
|
+
"正在結合產品資訊和限制條件產生首輪通路策略;頁面會顯示實際耗時和可用的真實進展。",
|
|
321
321
|
loadingSteps: [
|
|
322
322
|
{ status: "complete", title: "讀取產品資訊", countdown: "" },
|
|
323
323
|
{ status: "active", title: "比較通路選項", countdown: "" },
|
|
@@ -326,6 +326,9 @@ const coldStartChannelSelector = {
|
|
|
326
326
|
loadingTip:
|
|
327
327
|
"冷啟動通常先驗證一個低成本社區、內容或夥伴通路,再擴展到更重的廣告和長期 SEO。",
|
|
328
328
|
runningStatusLabel: "正在產生通路策略",
|
|
329
|
+
runningWaiting: "任務已提交,等待下一條真實進展",
|
|
330
|
+
runningElapsed: "已運行 {time}",
|
|
331
|
+
runningLastActivity: "最近進展更新於 {time} 前",
|
|
329
332
|
runningTipLabel: "提示",
|
|
330
333
|
reportTitle: "客戶獲取通路策略",
|
|
331
334
|
copyReport: "複製報告",
|
|
@@ -281,9 +281,14 @@ const productNameGenerator = {
|
|
|
281
281
|
"工作區右側視覺,展示粗略命名輸入被整理成三個候選方向和網域名稱後綴信號。",
|
|
282
282
|
loadingTitle: "正在產生與快篩",
|
|
283
283
|
loadingStatus: "正在準備候選名短名單",
|
|
284
|
-
loadingCountdown: "
|
|
284
|
+
loadingCountdown: "持續更新",
|
|
285
|
+
loadingStatePending: "任務已提交,正在等待 Agent 接手",
|
|
286
|
+
loadingStateRunning: "Agent 正在處理任務",
|
|
287
|
+
loadingStateFinalizing: "Agent 已完成分析,正在等待結構化結果",
|
|
288
|
+
loadingElapsed: "已運行 {time}",
|
|
289
|
+
loadingLastActivity: "最近進展更新於 {time} 前",
|
|
285
290
|
loadingHint:
|
|
286
|
-
"
|
|
291
|
+
"正在根據產品想法產生候選名,並做基礎讀音、長度和網域名稱後綴快篩。真實進展會自動更新;首輪不會做完整商標清關。",
|
|
287
292
|
loadingSteps: [
|
|
288
293
|
"產生命名策略和候選名",
|
|
289
294
|
"運行讀音、長度和基礎品牌篩選",
|
|
@@ -103,11 +103,11 @@ const startupIdeaResearch = {
|
|
|
103
103
|
workspaceHint: "描述想法和目標使用者,獲得公開需求信號、待驗證假設與下一步實驗。",
|
|
104
104
|
workspaceBadge: "創業想法驗證",
|
|
105
105
|
visualAlt: {
|
|
106
|
-
hero: "創業 idea
|
|
107
|
-
workspace: "
|
|
108
|
-
value: "
|
|
109
|
-
support: "
|
|
110
|
-
useCase: "
|
|
106
|
+
hero: "創業 idea 調研報告預覽",
|
|
107
|
+
workspace: "公開證據被整理成驗證報告與可編輯招募草案的三步流程",
|
|
108
|
+
value: "痛點訊號、用戶群體與付費意願線索的證據分析面板",
|
|
109
|
+
support: "競品證據矩陣與訪談、落地頁、付費驗證實驗",
|
|
110
|
+
useCase: "待創辦人審核的招募海報、短帖草案與真實回饋空狀態",
|
|
111
111
|
},
|
|
112
112
|
valueKicker: "你會得到什麼",
|
|
113
113
|
valueTitle: "不是替你拍腦袋,而是把 idea 拆成可驗證證據",
|