@tomako/tools-runtime 0.1.7 → 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.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/src/components/tools/engine/use-legacy-llm-task.ts +23 -1
  3. package/src/components/tools/engine/use-task-activity.ts +37 -0
  4. package/src/components/tools/workspace/tool-loading-panel.tsx +16 -2
  5. package/src/features/tools/cold-start-channel-selector/widget/cold-start-channel-selector.tsx +51 -7
  6. package/src/features/tools/cold-start-channel-selector/widget/preview-panels.tsx +22 -2
  7. package/src/features/tools/cold-start-channel-selector/widget/types.ts +8 -2
  8. package/src/features/tools/product-name-generator/widget/product-name-generator.tsx +64 -33
  9. package/src/features/tools/product-name-generator/widget/types.ts +8 -1
  10. package/src/features/tools/startup-idea-research/startup-idea-research.container.tsx +4 -4
  11. package/src/i18n/messages/en/tools/app-store-screenshot-generator.ts +2 -2
  12. package/src/i18n/messages/en/tools/cold-start-channel-selector.ts +4 -1
  13. package/src/i18n/messages/en/tools/product-name-generator.ts +7 -2
  14. package/src/i18n/messages/en/tools/startup-idea-research.ts +5 -5
  15. package/src/i18n/messages/zh/tools/app-store-screenshot-generator.ts +2 -2
  16. package/src/i18n/messages/zh/tools/cold-start-channel-selector.ts +4 -1
  17. package/src/i18n/messages/zh/tools/product-name-generator.ts +7 -2
  18. package/src/i18n/messages/zh/tools/startup-idea-research.ts +5 -5
  19. package/src/i18n/messages/zh-tw/tools/cold-start-channel-selector.ts +4 -1
  20. package/src/i18n/messages/zh-tw/tools/product-name-generator.ts +7 -2
  21. package/src/i18n/messages/zh-tw/tools/startup-idea-research.ts +5 -5
  22. package/src/lib/static-asset.test.ts +36 -0
  23. package/src/lib/static-asset.ts +7 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomako/tools-runtime",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Shared interactive tools runtime for Tomako-FE workspace dialogs and Tomako-SEO tool pages",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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: number;
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 value={progress} className="h-1.5 bg-[#EEF1F5] [&_[data-slot=progress-indicator]]:bg-[#111111]" />
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";
@@ -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 { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
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 = t.raw("loadingSteps") as readonly LoadingStep[];
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
- return { settled: false as const };
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
- setState({ phase: "running", status: startedAtMs ? "RESTORING" : "PENDING" });
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({ phase: "running", status });
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={42}
197
- className="h-1.5 bg-[#EEF1F5] [&_[data-slot=progress-indicator]]:bg-[#111111]"
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
- | { phase: "running"; status: string }
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 { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
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 loadingStepTitles = useMemo(
152
- () => (t.raw("loadingSteps") as readonly string[]).slice(0, SCREEN_LOADING_STEP_COUNT),
153
- [t],
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
- const activeStep =
157
- screenState.phase === "loading"
158
- ? screenLoadingStepIndex(screenState.status)
159
- : 0;
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
- : "pending",
169
- countdown:
170
- screenState.phase === "loading" && index === activeStep
171
- ? t("loadingCountdown")
172
- : undefined,
173
- }));
174
- }, [loadingStepTitles, screenState, t]);
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
- setScreenState({ phase: "loading", status: task.status, taskId });
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({ phase: "loading", status: nextStatus, taskId });
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
- | { phase: "loading"; status: string; taskId?: string }
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-hero.webp"
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-hero.webp" alt={t("visualAlt.value")} />
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-hero.webp" alt={t("visualAlt.support")} />
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-hero.webp" alt={t("visualAlt.useCase")} />
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 URLs, missing screenshots, unsupported file types, and export failures return actionable messages instead of empty creatives.",
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, check that the URL is not empty, the uploaded file is PNG/JPG/WebP, browser file reading is allowed, and the selected input source matches your data.",
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 to build your first-channel strategy. This can take a minute or two.",
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: "about 1 min",
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. The first pass does not perform full trademark clearance.",
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 (asset pending)",
111
- workspace: "From rough idea to validation report and recruiting assets (asset pending)",
112
- value: "Pain, user, and payment signal visual (asset pending)",
113
- support: "Comparable product and validation experiment visual (asset pending)",
114
- useCase: "Recruiting copy and poster draft visual (asset pending)",
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: " URL、未上传截图、格式不支持或导出失败时,结果区会给出可恢复提示,而不是生成空素材。",
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
- "如果没有结果,先检查 URL 是否为空、上传文件是否为 PNG/JPG/WebP、浏览器是否阻止本地文件读取,以及是否选择了正确的输入来源。",
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: "约 1 分钟内",
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: "約 1 分鐘內",
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 拆成可驗證證據",
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import { staticAsset } from "./static-asset.ts";
5
+
6
+ function withStaticOrigin(value: string | undefined, run: () => void) {
7
+ const previous = process.env.NEXT_PUBLIC_STATIC_ORIGIN;
8
+ if (value === undefined) delete process.env.NEXT_PUBLIC_STATIC_ORIGIN;
9
+ else process.env.NEXT_PUBLIC_STATIC_ORIGIN = value;
10
+ try {
11
+ run();
12
+ } finally {
13
+ if (previous === undefined) delete process.env.NEXT_PUBLIC_STATIC_ORIGIN;
14
+ else process.env.NEXT_PUBLIC_STATIC_ORIGIN = previous;
15
+ }
16
+ }
17
+
18
+ describe("staticAsset", () => {
19
+ it("keeps public assets same-origin until a CDN origin is explicitly configured", () => {
20
+ withStaticOrigin(undefined, () => {
21
+ assert.equal(
22
+ staticAsset("/tools/mrr-calculator-section-primary-8bit.webp"),
23
+ "/tools/mrr-calculator-section-primary-8bit.webp",
24
+ );
25
+ });
26
+ });
27
+
28
+ it("uses the configured CDN origin when it is available", () => {
29
+ withStaticOrigin("https://static.tomako.ai/", () => {
30
+ assert.equal(
31
+ staticAsset("/tools/mrr-calculator-section-primary-8bit.webp"),
32
+ "https://static.tomako.ai/static/tools/mrr-calculator-section-primary-8bit.webp",
33
+ );
34
+ });
35
+ });
36
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Resolve root-relative marketing asset paths to the static CDN.
2
+ * Resolve root-relative marketing asset paths to an explicitly configured static CDN.
3
3
  * Mirrors `@tomako/public-chrome` staticAsset so tools-runtime stays free of
4
4
  * a chrome peer dependency.
5
5
  *
@@ -10,8 +10,6 @@
10
10
  const STYLE_SUFFIX_PATTERN = /!(default|256|640|960|1600|2560)$/;
11
11
  const RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i;
12
12
 
13
- export const DEFAULT_STATIC_ORIGIN = "https://static.tomako.ai";
14
-
15
13
  export type OssImageStyle =
16
14
  | "default"
17
15
  | "256"
@@ -20,13 +18,9 @@ export type OssImageStyle =
20
18
  | "1600"
21
19
  | "2560";
22
20
 
23
- function readStaticOrigin(): string {
24
- const raw = process.env.NEXT_PUBLIC_STATIC_ORIGIN;
25
- if (raw !== undefined) {
26
- const value = raw.trim().replace(/\/$/, "");
27
- if (value.length > 0) return value;
28
- }
29
- return DEFAULT_STATIC_ORIGIN;
21
+ function readStaticOrigin(): string | undefined {
22
+ const value = process.env.NEXT_PUBLIC_STATIC_ORIGIN?.trim().replace(/\/$/, "");
23
+ return value || undefined;
30
24
  }
31
25
 
32
26
  export function staticAsset(path: string, style?: OssImageStyle): string {
@@ -39,6 +33,9 @@ export function staticAsset(path: string, style?: OssImageStyle): string {
39
33
 
40
34
  const normalized = path.startsWith("/") ? path : `/${path}`;
41
35
  const origin = readStaticOrigin();
36
+ // A deployed page always has the corresponding public/ asset. CDN is an
37
+ // optional performance layer, never the only way to render a tool visual.
38
+ if (!origin) return normalized;
42
39
  const absolute = `${origin}/static${normalized}`;
43
40
  if (!style || !RASTER_EXT.test(normalized.replace(STYLE_SUFFIX_PATTERN, ""))) {
44
41
  return absolute;