1187 lines
46 KiB
TypeScript
1187 lines
46 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
cancelPMFlowLaunchDraftJob,
|
||
createPMFlowLaunchDraftJob,
|
||
confirmPMFlowLaunchDraft,
|
||
getPMFlowLaunchDraftJob,
|
||
revisePMFlowLaunchDraft,
|
||
type PMFlowLaunchDraftJob,
|
||
type PMFlowLaunchDraft,
|
||
type PMFlowLaunchDraftItem,
|
||
} from "@/api/pmflowLaunch";
|
||
import { spaceApi } from "@/api/space";
|
||
import { getAuthToken } from "@/lib/api";
|
||
import { normalizeApiError } from "@/lib/apiError";
|
||
import {
|
||
consumePendingIntent,
|
||
savePendingIntent,
|
||
type ProjectLaunchDraftPendingPayload,
|
||
type ProjectLaunchPendingPayload,
|
||
} from "@/lib/pendingIntent";
|
||
import {
|
||
aiOperationItemsMock,
|
||
aiWorkStepsMock,
|
||
launchInputMock,
|
||
projectDraftMock,
|
||
systemStatusMock,
|
||
teamHintMock,
|
||
} from "@/features/project-launch/mockData";
|
||
import type { AIOperationItem, AIWorkStep, AIWorkStepStatus, LaunchInputMode, LaunchStage, LaunchStateSnapshot, ProjectDraft } from "@/features/project-launch/types";
|
||
|
||
const terminalStepIndex = aiWorkStepsMock.length - 1;
|
||
const PROJECT_LAUNCH_PREFILL_KEY = "pmflow_project_launch_prefill";
|
||
|
||
function compactText(text: string, maxLength = 88): string {
|
||
const normalized = text.replace(/\s+/g, " ").trim();
|
||
if (!normalized) return "暂无输入摘要";
|
||
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
|
||
}
|
||
|
||
function countText(count: number, unit = "项"): string {
|
||
return count > 0 ? `${count} ${unit}` : "待补充";
|
||
}
|
||
|
||
function formatOperationClock(timestamp: number | null | undefined): string {
|
||
if (!timestamp) return "待返回";
|
||
const date = new Date(timestamp);
|
||
if (Number.isNaN(date.getTime())) return "待返回";
|
||
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
}
|
||
|
||
const emptyProjectDraft: ProjectDraft = {
|
||
name: "",
|
||
oneLineGoal: "",
|
||
targetUsers: [],
|
||
mvpScope: {
|
||
included: [],
|
||
excluded: [],
|
||
},
|
||
modelDecisionSuggestion: "",
|
||
risks: [],
|
||
confirmations: [],
|
||
firstTasks: [],
|
||
};
|
||
|
||
function cloneDraft(draft: ProjectDraft): ProjectDraft {
|
||
return {
|
||
...draft,
|
||
targetUsers: [...draft.targetUsers],
|
||
mvpScope: {
|
||
included: [...draft.mvpScope.included],
|
||
excluded: [...draft.mvpScope.excluded],
|
||
},
|
||
risks: draft.risks.map((risk) => ({ ...risk })),
|
||
confirmations: draft.confirmations.map((item) => ({ ...item })),
|
||
firstTasks: draft.firstTasks.map((task) => ({ ...task })),
|
||
};
|
||
}
|
||
|
||
function getInitialStage(): LaunchStage {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const stage = params.get("state");
|
||
if (
|
||
stage === "intent_input" ||
|
||
stage === "ai_working" ||
|
||
stage === "human_review" ||
|
||
stage === "draft_edit" ||
|
||
stage === "generation_failed" ||
|
||
stage === "creation_success"
|
||
) {
|
||
return stage;
|
||
}
|
||
return "intent_input";
|
||
}
|
||
|
||
function getDataSourceMode(): "api" | "demo" {
|
||
const params = new URLSearchParams(window.location.search);
|
||
return params.get("demo") === "1" || params.get("dataSource") === "demo" ? "demo" : "api";
|
||
}
|
||
|
||
type InitialLaunchInput = { mode: LaunchInputMode; text: string; autoStart: boolean; launchSeed?: Record<string, unknown> };
|
||
|
||
function consumeProjectLaunchPrefill(): InitialLaunchInput | null {
|
||
try {
|
||
const raw = window.sessionStorage.getItem(PROJECT_LAUNCH_PREFILL_KEY) || window.localStorage.getItem(PROJECT_LAUNCH_PREFILL_KEY);
|
||
if (!raw) return null;
|
||
window.sessionStorage.removeItem(PROJECT_LAUNCH_PREFILL_KEY);
|
||
window.localStorage.removeItem(PROJECT_LAUNCH_PREFILL_KEY);
|
||
const payload = JSON.parse(raw) as { mode?: string; text?: string; autoStart?: boolean; launchSeed?: unknown; launch_seed?: unknown } | null;
|
||
const text = typeof payload?.text === "string" ? payload.text.trim() : "";
|
||
if (!text) return null;
|
||
const mode = payload?.mode === "import" || payload?.mode === "template" ? payload.mode : "idea";
|
||
const launchSeed = normalizeLaunchSeed(payload?.launchSeed || payload?.launch_seed);
|
||
return {
|
||
mode,
|
||
text: text.slice(0, 2000),
|
||
autoStart: Boolean(payload?.autoStart),
|
||
...(launchSeed ? { launchSeed } : {}),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getInitialLaunchInput(dataSourceMode: "api" | "demo", pendingDraft: ProjectLaunchDraftPendingPayload | null): InitialLaunchInput {
|
||
if (dataSourceMode === "demo") return { ...launchInputMock, autoStart: false };
|
||
if (pendingDraft?.text) {
|
||
return {
|
||
mode: pendingDraft.mode || "idea",
|
||
text: pendingDraft.text,
|
||
autoStart: false,
|
||
};
|
||
}
|
||
const params = new URLSearchParams(window.location.search);
|
||
const actionPrefill = consumeProjectLaunchPrefill();
|
||
if (actionPrefill) return actionPrefill;
|
||
const prefill = params.get("prefill")?.trim();
|
||
if (prefill) {
|
||
return {
|
||
mode: "idea" as const,
|
||
text: prefill.slice(0, 2000),
|
||
autoStart: params.get("autostart") === "1",
|
||
};
|
||
}
|
||
const pending = consumePendingIntent<ProjectLaunchPendingPayload>("project_launch_input");
|
||
if (pending?.payload?.text) {
|
||
return {
|
||
mode: pending.payload.mode || "idea",
|
||
text: pending.payload.text,
|
||
autoStart: false,
|
||
...(normalizeLaunchSeed(pending.payload.launchSeed) ? { launchSeed: normalizeLaunchSeed(pending.payload.launchSeed) } : {}),
|
||
};
|
||
}
|
||
return { mode: "idea" as const, text: "", autoStart: false };
|
||
}
|
||
|
||
function saveProjectLaunchInput(input: LaunchStateSnapshot["input"]): void {
|
||
savePendingIntent<ProjectLaunchPendingPayload>({
|
||
intentType: "project_launch_input",
|
||
route: "/project-launch",
|
||
payload: {
|
||
text: input.text,
|
||
mode: input.mode,
|
||
...(input.launchSeed ? { launchSeed: input.launchSeed } : {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
function normalizeLaunchSeed(value: unknown): Record<string, unknown> | undefined {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function buildLaunchDraftAttachments(input: LaunchStateSnapshot["input"]): Array<Record<string, unknown>> {
|
||
const attachments: Array<Record<string, unknown>> = [];
|
||
if (input.launchSeed) {
|
||
attachments.push({
|
||
type: "pmflow_launch_seed",
|
||
name: "Space AI 本轮产品方案种子",
|
||
summary: "来自 PMFlow AI 本轮回答的产品定位、MVP 边界、风险、验证路径和下一步动作,用于保持项目草案与回答口径一致。",
|
||
payload: input.launchSeed,
|
||
});
|
||
}
|
||
if (input.importedMaterial) attachments.push({ ...input.importedMaterial });
|
||
return attachments;
|
||
}
|
||
|
||
function draftGroupLabel(group: PMFlowLaunchDraftItem["group"]): ProjectDraft["firstTasks"][number]["group"] {
|
||
if (group === "engineering") return "开发任务";
|
||
if (group === "qa") return "测试任务";
|
||
return "产品任务";
|
||
}
|
||
|
||
function riskLevelLabel(level: PMFlowLaunchDraftItem["level"]): ProjectDraft["risks"][number]["level"] {
|
||
if (level === "high") return "高";
|
||
if (level === "low") return "低";
|
||
return "中";
|
||
}
|
||
|
||
function mapLaunchDraftToProjectDraft(payload: PMFlowLaunchDraft): ProjectDraft {
|
||
return {
|
||
name: payload.title,
|
||
oneLineGoal: payload.one_liner,
|
||
targetUsers: payload.target_user
|
||
.split(/[、,/,\s]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
mvpScope: {
|
||
included: payload.requirements.map((item) => item.title),
|
||
excluded: [],
|
||
},
|
||
modelDecisionSuggestion: payload.solution,
|
||
risks: payload.risks.map((risk, index) => ({
|
||
id: `risk-${index}`,
|
||
title: risk.title,
|
||
level: riskLevelLabel(risk.level),
|
||
summary: risk.summary || risk.mitigation || "",
|
||
statusLabel: "待你确认",
|
||
})),
|
||
confirmations: payload.decisions.map((decision, index) => ({
|
||
id: `decision-${index}`,
|
||
title: decision.title,
|
||
reason: decision.reason || decision.summary || "",
|
||
statusLabel: "待你确认",
|
||
})),
|
||
firstTasks: payload.tasks.map((task, index) => ({
|
||
id: `task-${index}`,
|
||
group: draftGroupLabel(task.group),
|
||
title: task.title,
|
||
summary: task.summary || "",
|
||
owner: "未分配",
|
||
due: "待定",
|
||
statusLabel: "AI 已生成",
|
||
})),
|
||
};
|
||
}
|
||
|
||
function mapProjectDraftToEditedPayload(draft: ProjectDraft, rawDraft: Record<string, unknown> | null) {
|
||
const raw = { ...(rawDraft || {}) };
|
||
return {
|
||
...raw,
|
||
title: draft.name,
|
||
one_liner: draft.oneLineGoal,
|
||
target_user: draft.targetUsers.join("、"),
|
||
solution: draft.modelDecisionSuggestion || String(raw.solution || ""),
|
||
requirements: draft.mvpScope.included.map((title) => ({ title, summary: title, acceptance_criteria: [] })),
|
||
risks: draft.risks.map((risk) => ({
|
||
title: risk.title,
|
||
summary: risk.summary,
|
||
level: risk.level === "高" ? "high" : risk.level === "低" ? "low" : "medium",
|
||
mitigation: "",
|
||
})),
|
||
decisions: draft.confirmations.map((item) => ({ title: item.title, reason: item.reason, owner: "" })),
|
||
tasks: draft.firstTasks.map((task, index) => ({
|
||
title: task.title,
|
||
summary: task.summary,
|
||
group: task.group === "开发任务" ? "engineering" : task.group === "测试任务" ? "qa" : "product",
|
||
priority: "medium",
|
||
acceptance_criteria: [],
|
||
requirement_index: index % Math.max(1, draft.mvpScope.included.length),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function buildApiStepTemplates(snapshot: LaunchStateSnapshot): AIWorkStep[] {
|
||
const draft = snapshot.draft;
|
||
const targetUserCount = draft.targetUsers.length;
|
||
const requirementCount = draft.mvpScope.included.length;
|
||
const riskCount = draft.risks.length;
|
||
const taskCount = draft.firstTasks.length;
|
||
const steps: AIWorkStep[] = [
|
||
{
|
||
id: "goal",
|
||
title: "定位变现机会",
|
||
description: snapshot.stage === "ai_working" ? "正在从原始输入整理用户场景与付费假设..." : compactText(draft.oneLineGoal || snapshot.input.text, 64),
|
||
estimate: snapshot.stage === "ai_working" ? "处理中" : "已完成",
|
||
generatedCount: draft.oneLineGoal ? 1 : 0,
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "users",
|
||
title: "识别目标用户",
|
||
description: targetUserCount > 0 ? `已整理 ${targetUserCount} 类目标用户` : "目标用户信息不足,等待补充或人工确认",
|
||
estimate: snapshot.stage === "ai_working" ? "处理中" : "已完成",
|
||
generatedCount: targetUserCount,
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "scope",
|
||
title: "提取 MVP 范围",
|
||
description: requirementCount > 0 ? `已生成 ${requirementCount} 条首版需求范围` : "MVP 范围不足,等待补充",
|
||
estimate: snapshot.stage === "ai_working" ? "处理中" : "已完成",
|
||
generatedCount: requirementCount,
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "risks",
|
||
title: "识别变现风险",
|
||
description: riskCount > 0 ? `已生成 ${riskCount} 条风险与待确认项` : "风险证据不足,等待补充",
|
||
estimate: snapshot.stage === "ai_working" ? "处理中" : "已完成",
|
||
generatedCount: riskCount,
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "tasks",
|
||
title: "生成首批任务",
|
||
description: taskCount > 0 ? `已拆解 ${taskCount} 条首批任务建议` : "任务拆解不足,等待补充",
|
||
estimate: snapshot.stage === "ai_working" ? "处理中" : "已完成",
|
||
generatedCount: taskCount,
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "confirmations",
|
||
title: "准备待确认草案",
|
||
description: snapshot.warnings.length > 0 ? `仍有 ${snapshot.warnings.length} 个缺口需要确认` : "已准备待确认草案",
|
||
estimate: snapshot.stage === "ai_working" ? "处理中" : "已完成",
|
||
generatedCount: snapshot.warnings.length,
|
||
status: "pending",
|
||
},
|
||
];
|
||
return steps;
|
||
}
|
||
|
||
function buildApiOperations(snapshot: LaunchStateSnapshot): AIOperationItem[] {
|
||
if (snapshot.stage === "ai_working") {
|
||
if (snapshot.paused) {
|
||
return [
|
||
{
|
||
id: "generation-paused",
|
||
type: "identified",
|
||
title: "已暂停生成",
|
||
summary: "已停止等待当前草案生成结果。点击继续生成会基于当前输入重新发起生成任务。",
|
||
statusLabel: "待你确认",
|
||
timestamp: "已暂停",
|
||
items: [compactText(snapshot.input.text, 72)],
|
||
},
|
||
];
|
||
}
|
||
const operations: AIOperationItem[] = [
|
||
{
|
||
id: "input-analysis",
|
||
type: "identified",
|
||
title: "正在分析你的输入",
|
||
summary: "正在调用项目草案生成,等待结果返回。",
|
||
statusLabel: "AI 正在处理",
|
||
timestamp: `开始 ${formatOperationClock(snapshot.generationStartedAt || snapshot.inputUpdatedAt)}`,
|
||
items: [compactText(snapshot.input.text, 72)],
|
||
},
|
||
];
|
||
const foregroundWaitSeconds = snapshot.generationForegroundWaitSeconds ?? 10;
|
||
if (snapshot.elapsedSeconds >= foregroundWaitSeconds) {
|
||
operations.push({
|
||
id: "foreground-wait-exceeded",
|
||
type: "identified",
|
||
title: "已超过前台等待窗口",
|
||
summary: `当前生成任务仍在运行,上限 ${snapshot.generationJobTimeoutSeconds ?? 180} 秒,结果返回后会自动进入草案确认。`,
|
||
statusLabel: "AI 正在处理",
|
||
timestamp: `已等待 ${snapshot.elapsedSeconds}s`,
|
||
items: snapshot.generationLargeInputPolicy === "preprocess_then_generate" ? ["大输入策略:预处理后生成"] : [],
|
||
});
|
||
}
|
||
return operations;
|
||
}
|
||
|
||
const draft = snapshot.draft;
|
||
if (!snapshot.draftId && !snapshot.rawDraft) return [];
|
||
|
||
const targetUsers = draft.targetUsers.length > 0 ? draft.targetUsers : ["目标用户待补充"];
|
||
const requirements = draft.mvpScope.included.length > 0 ? draft.mvpScope.included : ["MVP 范围待补充"];
|
||
const risks = draft.risks.length > 0 ? draft.risks.map((risk) => risk.title) : ["风险信息待补充"];
|
||
const tasks = draft.firstTasks.length > 0 ? draft.firstTasks.map((task) => task.title) : ["首批任务待补充"];
|
||
const completedAt = `完成 ${formatOperationClock(snapshot.generationFinishedAt || snapshot.inputUpdatedAt)}`;
|
||
const operations: AIOperationItem[] = [
|
||
{
|
||
id: "target-users",
|
||
type: "identified",
|
||
title: `已整理目标用户(${countText(draft.targetUsers.length, "类")})`,
|
||
summary: "基于真实草案返回结果整理,未命中的字段会保留为待补充。",
|
||
statusLabel: "AI 已生成",
|
||
timestamp: completedAt,
|
||
items: targetUsers.slice(0, 4),
|
||
},
|
||
{
|
||
id: "requirements",
|
||
type: "generated",
|
||
title: `已生成核心需求(${countText(draft.mvpScope.included.length, "条")})`,
|
||
summary: draft.modelDecisionSuggestion || "需求范围来自真实草案结果,仍需你确认边界。",
|
||
statusLabel: "AI 已生成",
|
||
timestamp: completedAt,
|
||
items: requirements.slice(0, 4),
|
||
},
|
||
{
|
||
id: "risks",
|
||
type: "risk",
|
||
title: `已识别变现风险(${countText(draft.risks.length, "条")})`,
|
||
summary: snapshot.warnings.length > 0 ? "草案仍有信息缺口,建议先补充再创建项目。" : "风险来自真实草案结果,正式创建前仍需人工确认。",
|
||
statusLabel: "待你确认",
|
||
timestamp: completedAt,
|
||
items: risks.slice(0, 4),
|
||
},
|
||
{
|
||
id: "tasks",
|
||
type: "confirmation",
|
||
title: `已准备首批任务(${countText(draft.firstTasks.length, "条")})`,
|
||
summary: "任务只是待确认建议,确认创建项目后才会写入真实任务对象。",
|
||
statusLabel: "待你确认",
|
||
timestamp: completedAt,
|
||
items: tasks.slice(0, 4),
|
||
},
|
||
];
|
||
return operations.slice(0, Math.max(1, snapshot.visibleOperationCount));
|
||
}
|
||
|
||
export function useProjectLaunchState() {
|
||
const [snapshot, setSnapshot] = useState<LaunchStateSnapshot>(() => {
|
||
const now = Date.now();
|
||
const dataSourceMode = getDataSourceMode();
|
||
const pendingDraftIntent = dataSourceMode === "api"
|
||
? consumePendingIntent<ProjectLaunchDraftPendingPayload>("project_launch_draft")
|
||
: null;
|
||
const pendingDraft = pendingDraftIntent?.payload ?? null;
|
||
const initialInput = getInitialLaunchInput(dataSourceMode, pendingDraft);
|
||
const autoStartGeneration = dataSourceMode === "api" && !pendingDraft && initialInput.autoStart && Boolean(initialInput.text.trim());
|
||
const stage = dataSourceMode === "demo" ? getInitialStage() : pendingDraft ? "human_review" : "intent_input";
|
||
const activeStepIndex = stage === "ai_working" ? 2 : stage === "intent_input" || stage === "generation_failed" ? 0 : terminalStepIndex;
|
||
const initialDraft = pendingDraft
|
||
? mapLaunchDraftToProjectDraft(pendingDraft.rawDraft as unknown as PMFlowLaunchDraft)
|
||
: dataSourceMode === "demo"
|
||
? cloneDraft(projectDraftMock)
|
||
: cloneDraft(emptyProjectDraft);
|
||
return {
|
||
dataSourceMode,
|
||
stage,
|
||
input: { mode: initialInput.mode, text: initialInput.text, ...(initialInput.launchSeed ? { launchSeed: initialInput.launchSeed } : {}) },
|
||
draft: initialDraft,
|
||
editingDraft: cloneDraft(initialDraft),
|
||
systemStatus: systemStatusMock,
|
||
teamHint: teamHintMock,
|
||
activeStepIndex,
|
||
visibleOperationCount: stage === "ai_working" ? 2 : aiOperationItemsMock.length,
|
||
paused: false,
|
||
elapsedSeconds: stage === "ai_working" ? 102 : 0,
|
||
generationStartedAt: stage === "ai_working" ? now - 102000 : null,
|
||
generationFinishedAt: pendingDraft ? now : null,
|
||
inputUpdatedAt: initialInput.text ? now : null,
|
||
savedIdea: false,
|
||
savingIdea: false,
|
||
creatingProject: false,
|
||
savedIdeaId: null,
|
||
savedIdeaHref: null,
|
||
savedIdeaError: null,
|
||
generationJobId: null,
|
||
generationRevisionId: null,
|
||
generationJobStatus: null,
|
||
generationForegroundWaitSeconds: null,
|
||
generationJobTimeoutSeconds: null,
|
||
generationLargeInputPolicy: null,
|
||
generationChangedFields: [],
|
||
draftId: pendingDraft?.draftId || null,
|
||
redirectUrl: null,
|
||
errorMessage: null,
|
||
failureKind: null,
|
||
warnings: [],
|
||
rawDraft: pendingDraft?.rawDraft || null,
|
||
autoStartGeneration,
|
||
};
|
||
});
|
||
const latestSnapshotRef = useRef(snapshot);
|
||
|
||
useEffect(() => {
|
||
latestSnapshotRef.current = snapshot;
|
||
}, [snapshot]);
|
||
|
||
useEffect(() => {
|
||
if (snapshot.dataSourceMode !== "demo" || snapshot.stage !== "ai_working" || snapshot.paused) return undefined;
|
||
const interval = window.setInterval(() => {
|
||
setSnapshot((current) => {
|
||
if (current.stage !== "ai_working" || current.paused) return current;
|
||
const nextElapsed = current.elapsedSeconds + 7;
|
||
const nextVisibleCount = Math.min(aiOperationItemsMock.length, current.visibleOperationCount + 1);
|
||
const nextStepIndex = Math.min(terminalStepIndex, current.activeStepIndex + 1);
|
||
if (current.activeStepIndex >= terminalStepIndex) {
|
||
return {
|
||
...current,
|
||
stage: "human_review",
|
||
activeStepIndex: terminalStepIndex,
|
||
visibleOperationCount: aiOperationItemsMock.length,
|
||
elapsedSeconds: nextElapsed,
|
||
};
|
||
}
|
||
return {
|
||
...current,
|
||
activeStepIndex: nextStepIndex,
|
||
visibleOperationCount: nextVisibleCount,
|
||
elapsedSeconds: nextElapsed,
|
||
};
|
||
});
|
||
}, 850);
|
||
return () => window.clearInterval(interval);
|
||
}, [snapshot.dataSourceMode, snapshot.stage, snapshot.paused]);
|
||
|
||
useEffect(() => {
|
||
if (snapshot.dataSourceMode !== "api" || snapshot.stage !== "ai_working" || snapshot.paused || !snapshot.generationStartedAt) return undefined;
|
||
const tick = () => {
|
||
setSnapshot((current) => {
|
||
if (current.dataSourceMode !== "api" || current.stage !== "ai_working" || !current.generationStartedAt) return current;
|
||
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - current.generationStartedAt) / 1000));
|
||
const foregroundWaitSeconds = current.generationForegroundWaitSeconds ?? 10;
|
||
const isPastForegroundWait = elapsedSeconds >= foregroundWaitSeconds;
|
||
return {
|
||
...current,
|
||
elapsedSeconds,
|
||
activeStepIndex: isPastForegroundWait ? Math.max(current.activeStepIndex, 1) : current.activeStepIndex,
|
||
visibleOperationCount: isPastForegroundWait ? Math.max(current.visibleOperationCount, 2) : current.visibleOperationCount,
|
||
systemStatus: isPastForegroundWait
|
||
? { ...current.systemStatus, message: "AI 仍在生成草案,系统会继续轮询结果", updatedAt: "刚刚" }
|
||
: current.systemStatus,
|
||
};
|
||
});
|
||
};
|
||
tick();
|
||
const interval = window.setInterval(tick, 1000);
|
||
return () => window.clearInterval(interval);
|
||
}, [snapshot.dataSourceMode, snapshot.stage, snapshot.paused, snapshot.generationStartedAt]);
|
||
|
||
const applyJobResult = useCallback((job: PMFlowLaunchDraftJob) => {
|
||
if (job.status === "completed" && job.draft) {
|
||
const draft = mapLaunchDraftToProjectDraft(job.draft);
|
||
setSnapshot((current) => {
|
||
if (current.generationJobId && current.generationJobId !== job.job_id) return current;
|
||
return {
|
||
...current,
|
||
stage: "human_review",
|
||
activeStepIndex: terminalStepIndex,
|
||
visibleOperationCount: aiOperationItemsMock.length,
|
||
elapsedSeconds: current.generationStartedAt ? Math.max(0, Math.floor((Date.now() - current.generationStartedAt) / 1000)) : current.elapsedSeconds,
|
||
generationStartedAt: null,
|
||
generationFinishedAt: Date.now(),
|
||
generationJobId: job.job_id,
|
||
generationRevisionId: job.revision_id,
|
||
generationJobStatus: job.status,
|
||
generationForegroundWaitSeconds: job.runtime_policy?.foreground_wait_seconds ?? current.generationForegroundWaitSeconds,
|
||
generationJobTimeoutSeconds: job.runtime_policy?.job_timeout_seconds ?? current.generationJobTimeoutSeconds,
|
||
generationLargeInputPolicy: job.runtime_policy?.large_input_policy ?? current.generationLargeInputPolicy,
|
||
generationChangedFields: job.changed_fields || [],
|
||
draft,
|
||
editingDraft: cloneDraft(draft),
|
||
draftId: job.draft.draft_id,
|
||
rawDraft: job.draft as unknown as Record<string, unknown>,
|
||
warnings: job.draft.warnings || [],
|
||
failureKind: null,
|
||
errorMessage: null,
|
||
systemStatus: { ...current.systemStatus, message: "真实项目草案已生成,等待确认", updatedAt: "刚刚" },
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
if (job.status === "failed" || job.status === "cancelled") {
|
||
setSnapshot((current) => {
|
||
if (current.generationJobId && current.generationJobId !== job.job_id) return current;
|
||
return {
|
||
...current,
|
||
stage: "generation_failed",
|
||
activeStepIndex: Math.min(current.activeStepIndex, 2),
|
||
visibleOperationCount: Math.max(current.visibleOperationCount, 1),
|
||
elapsedSeconds: current.generationStartedAt ? Math.max(0, Math.floor((Date.now() - current.generationStartedAt) / 1000)) : current.elapsedSeconds,
|
||
generationStartedAt: null,
|
||
generationJobStatus: job.status,
|
||
generationForegroundWaitSeconds: job.runtime_policy?.foreground_wait_seconds ?? current.generationForegroundWaitSeconds,
|
||
generationJobTimeoutSeconds: job.runtime_policy?.job_timeout_seconds ?? current.generationJobTimeoutSeconds,
|
||
generationLargeInputPolicy: job.runtime_policy?.large_input_policy ?? current.generationLargeInputPolicy,
|
||
errorMessage: job.error_message || "AI 暂时没有完成项目草案生成。你的输入已保留,可以稍后重试。",
|
||
failureKind: job.status === "cancelled" ? "system_failed" : "ai_generation_failed",
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
setSnapshot((current) => {
|
||
if (current.generationJobId && current.generationJobId !== job.job_id) return current;
|
||
return {
|
||
...current,
|
||
generationJobId: job.job_id,
|
||
generationRevisionId: job.revision_id,
|
||
generationJobStatus: job.status,
|
||
generationForegroundWaitSeconds: job.runtime_policy?.foreground_wait_seconds ?? current.generationForegroundWaitSeconds,
|
||
generationJobTimeoutSeconds: job.runtime_policy?.job_timeout_seconds ?? current.generationJobTimeoutSeconds,
|
||
generationLargeInputPolicy: job.runtime_policy?.large_input_policy ?? current.generationLargeInputPolicy,
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (snapshot.dataSourceMode !== "api" || snapshot.stage !== "ai_working" || snapshot.paused || !snapshot.generationJobId) return undefined;
|
||
const jobId = snapshot.generationJobId;
|
||
let stopped = false;
|
||
const poll = () => {
|
||
void getPMFlowLaunchDraftJob(jobId)
|
||
.then((job) => {
|
||
if (!stopped) applyJobResult(job);
|
||
})
|
||
.catch((error) => {
|
||
if (stopped) return;
|
||
const normalized = normalizeApiError(error, "ai_generation_failed");
|
||
setSnapshot((current) => {
|
||
if (current.generationJobId !== jobId) return current;
|
||
return {
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: normalized.message,
|
||
failureKind: normalized.kind,
|
||
};
|
||
});
|
||
});
|
||
};
|
||
poll();
|
||
const interval = window.setInterval(poll, 1500);
|
||
return () => {
|
||
stopped = true;
|
||
window.clearInterval(interval);
|
||
};
|
||
}, [applyJobResult, snapshot.dataSourceMode, snapshot.generationJobId, snapshot.paused, snapshot.stage]);
|
||
|
||
const steps = useMemo<AIWorkStep[]>(
|
||
() => {
|
||
const templates = snapshot.dataSourceMode === "demo" ? aiWorkStepsMock : buildApiStepTemplates(snapshot);
|
||
return templates.map((step, index) => ({
|
||
...step,
|
||
status: (snapshot.stage === "generation_failed" && index === snapshot.activeStepIndex
|
||
? "failed"
|
||
: index < snapshot.activeStepIndex || (snapshot.stage !== "ai_working" && index <= snapshot.activeStepIndex)
|
||
? "done"
|
||
: index === snapshot.activeStepIndex
|
||
? "active"
|
||
: "pending") as AIWorkStepStatus,
|
||
}));
|
||
},
|
||
[snapshot]
|
||
);
|
||
|
||
const operations = useMemo(
|
||
() => snapshot.dataSourceMode === "demo" && !snapshot.paused ? aiOperationItemsMock.slice(0, snapshot.visibleOperationCount) : buildApiOperations(snapshot),
|
||
[snapshot]
|
||
);
|
||
|
||
const updateInput = useCallback((text: string) => {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
input: { ...current.input, text },
|
||
inputUpdatedAt: Date.now(),
|
||
systemStatus: { ...current.systemStatus, message: "系统已保存输入", updatedAt: "刚刚" },
|
||
}));
|
||
}, []);
|
||
|
||
const setInputMode = useCallback((mode: LaunchInputMode) => {
|
||
setSnapshot((current) => ({ ...current, input: { ...current.input, mode } }));
|
||
}, []);
|
||
|
||
const startGeneration = useCallback(() => {
|
||
if (snapshot.dataSourceMode === "api") {
|
||
const input = snapshot.input.text.trim();
|
||
if (!input) return;
|
||
const generationStartedAt = Date.now();
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "ai_working",
|
||
activeStepIndex: 0,
|
||
visibleOperationCount: 1,
|
||
paused: false,
|
||
elapsedSeconds: 0,
|
||
generationStartedAt,
|
||
generationFinishedAt: null,
|
||
inputUpdatedAt: current.inputUpdatedAt || generationStartedAt,
|
||
savedIdea: false,
|
||
savedIdeaError: null,
|
||
generationJobId: null,
|
||
generationRevisionId: null,
|
||
generationJobStatus: "queued",
|
||
generationForegroundWaitSeconds: null,
|
||
generationJobTimeoutSeconds: null,
|
||
generationLargeInputPolicy: null,
|
||
generationChangedFields: [],
|
||
draftId: null,
|
||
rawDraft: null,
|
||
errorMessage: null,
|
||
failureKind: null,
|
||
autoStartGeneration: false,
|
||
warnings: [],
|
||
}));
|
||
if (!getAuthToken()) {
|
||
saveProjectLaunchInput(snapshot.input);
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
activeStepIndex: 0,
|
||
visibleOperationCount: Math.max(current.visibleOperationCount, 1),
|
||
generationStartedAt: null,
|
||
errorMessage: "需要登录后继续。你的输入已保留,登录后可以继续生成执行草案。",
|
||
failureKind: "auth_required",
|
||
}));
|
||
return;
|
||
}
|
||
void createPMFlowLaunchDraftJob({
|
||
input,
|
||
input_type: "text",
|
||
attachments: buildLaunchDraftAttachments(snapshot.input),
|
||
source: "web",
|
||
mode: "create",
|
||
})
|
||
.then((job) => {
|
||
const shouldCancelCreatedJob = latestSnapshotRef.current.paused;
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
generationJobId: job.job_id,
|
||
generationRevisionId: job.revision_id,
|
||
generationJobStatus: job.status,
|
||
generationForegroundWaitSeconds: job.runtime_policy?.foreground_wait_seconds ?? current.generationForegroundWaitSeconds,
|
||
generationJobTimeoutSeconds: job.runtime_policy?.job_timeout_seconds ?? current.generationJobTimeoutSeconds,
|
||
generationLargeInputPolicy: job.runtime_policy?.large_input_policy ?? current.generationLargeInputPolicy,
|
||
systemStatus: {
|
||
...current.systemStatus,
|
||
message: current.paused ? "生成已暂停,当前任务会被取消" : "草案生成任务已创建,正在等待 AI 返回",
|
||
updatedAt: "刚刚",
|
||
},
|
||
}));
|
||
if (shouldCancelCreatedJob) {
|
||
void cancelPMFlowLaunchDraftJob(job.job_id).catch(() => undefined);
|
||
return;
|
||
}
|
||
applyJobResult(job);
|
||
})
|
||
.catch((error) => {
|
||
const normalized = normalizeApiError(error, "ai_generation_failed");
|
||
if (normalized.kind === "auth_required" || normalized.kind === "session_expired") {
|
||
saveProjectLaunchInput(snapshot.input);
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
activeStepIndex: Math.min(current.activeStepIndex, 2),
|
||
visibleOperationCount: Math.max(current.visibleOperationCount, 1),
|
||
elapsedSeconds: current.generationStartedAt ? Math.max(0, Math.floor((Date.now() - current.generationStartedAt) / 1000)) : current.elapsedSeconds,
|
||
generationStartedAt: null,
|
||
errorMessage: normalized.message,
|
||
failureKind: normalized.kind,
|
||
}));
|
||
});
|
||
return;
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "ai_working",
|
||
activeStepIndex: 0,
|
||
visibleOperationCount: 1,
|
||
paused: false,
|
||
elapsedSeconds: 0,
|
||
generationStartedAt: Date.now(),
|
||
generationFinishedAt: null,
|
||
inputUpdatedAt: current.inputUpdatedAt || Date.now(),
|
||
savedIdea: false,
|
||
savedIdeaError: null,
|
||
errorMessage: null,
|
||
failureKind: null,
|
||
}));
|
||
}, [applyJobResult, snapshot.dataSourceMode, snapshot.input]);
|
||
|
||
useEffect(() => {
|
||
if (!snapshot.autoStartGeneration || snapshot.dataSourceMode !== "api" || snapshot.stage !== "intent_input") return;
|
||
if (!snapshot.input.text.trim()) {
|
||
setSnapshot((current) => ({ ...current, autoStartGeneration: false }));
|
||
return;
|
||
}
|
||
startGeneration();
|
||
}, [snapshot.autoStartGeneration, snapshot.dataSourceMode, snapshot.input.text, snapshot.stage, startGeneration]);
|
||
|
||
const togglePause = useCallback(() => {
|
||
if (snapshot.stage !== "ai_working") return;
|
||
if (snapshot.paused) {
|
||
startGeneration();
|
||
return;
|
||
}
|
||
const jobId = snapshot.generationJobId;
|
||
setSnapshot((current) => {
|
||
if (current.stage !== "ai_working") return current;
|
||
return {
|
||
...current,
|
||
paused: true,
|
||
generationStartedAt: null,
|
||
generationJobStatus: current.dataSourceMode === "api" && current.generationJobId ? "cancelled" : current.generationJobStatus,
|
||
systemStatus: {
|
||
...current.systemStatus,
|
||
message: current.dataSourceMode === "api" ? "已暂停生成,正在取消当前草案任务" : "已暂停生成",
|
||
updatedAt: "刚刚",
|
||
},
|
||
};
|
||
});
|
||
if (snapshot.dataSourceMode !== "api" || !jobId || !getAuthToken()) return;
|
||
void cancelPMFlowLaunchDraftJob(jobId).catch((error) => {
|
||
const normalized = normalizeApiError(error, "system_failed");
|
||
setSnapshot((current) => {
|
||
if (current.generationJobId !== jobId || !current.paused) return current;
|
||
return {
|
||
...current,
|
||
systemStatus: {
|
||
...current.systemStatus,
|
||
message: `暂停请求未完成:${normalized.message}`,
|
||
updatedAt: "刚刚",
|
||
},
|
||
};
|
||
});
|
||
});
|
||
}, [snapshot.dataSourceMode, snapshot.generationJobId, snapshot.paused, snapshot.stage, startGeneration]);
|
||
|
||
const supplementInput = useCallback((note?: string) => {
|
||
const supplement = String(note || "").trim();
|
||
if (!supplement) return;
|
||
const currentInput = snapshot.input.text.trim();
|
||
const nextInput = currentInput ? `${currentInput}\n补充:${supplement}` : supplement;
|
||
const generationStartedAt = Date.now();
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "ai_working",
|
||
input: { ...current.input, text: nextInput },
|
||
activeStepIndex: 0,
|
||
visibleOperationCount: Math.min(aiOperationItemsMock.length, Math.max(1, current.visibleOperationCount + 1)),
|
||
paused: false,
|
||
elapsedSeconds: 0,
|
||
generationStartedAt,
|
||
generationFinishedAt: null,
|
||
inputUpdatedAt: generationStartedAt,
|
||
generationJobId: null,
|
||
generationRevisionId: null,
|
||
generationJobStatus: "queued",
|
||
generationForegroundWaitSeconds: null,
|
||
generationJobTimeoutSeconds: null,
|
||
generationLargeInputPolicy: null,
|
||
generationChangedFields: [],
|
||
errorMessage: null,
|
||
failureKind: null,
|
||
systemStatus: { ...current.systemStatus, message: "已收到补充,正在生成新版草案", updatedAt: "刚刚" },
|
||
}));
|
||
if (!getAuthToken()) {
|
||
saveProjectLaunchInput({ ...snapshot.input, text: nextInput });
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: "需要登录后继续。你的输入已保留,登录后可以继续生成执行草案。",
|
||
failureKind: "auth_required",
|
||
}));
|
||
return;
|
||
}
|
||
const payload = {
|
||
input: snapshot.draftId ? supplement : nextInput,
|
||
input_type: "text" as const,
|
||
attachments: buildLaunchDraftAttachments(snapshot.input),
|
||
source: "web",
|
||
mode: "merge_supplement" as const,
|
||
revision_note: supplement,
|
||
parent_draft_id: snapshot.draftId,
|
||
};
|
||
const request = snapshot.draftId
|
||
? revisePMFlowLaunchDraft(snapshot.draftId, payload)
|
||
: createPMFlowLaunchDraftJob(payload);
|
||
void request
|
||
.then((job) => {
|
||
const shouldCancelCreatedJob = latestSnapshotRef.current.paused;
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
generationJobId: job.job_id,
|
||
generationRevisionId: job.revision_id,
|
||
generationJobStatus: job.status,
|
||
generationForegroundWaitSeconds: job.runtime_policy?.foreground_wait_seconds ?? current.generationForegroundWaitSeconds,
|
||
generationJobTimeoutSeconds: job.runtime_policy?.job_timeout_seconds ?? current.generationJobTimeoutSeconds,
|
||
generationLargeInputPolicy: job.runtime_policy?.large_input_policy ?? current.generationLargeInputPolicy,
|
||
systemStatus: {
|
||
...current.systemStatus,
|
||
message: current.paused ? "生成已暂停,当前修订任务会被取消" : "补充已进入 AI 草案修订任务",
|
||
updatedAt: "刚刚",
|
||
},
|
||
}));
|
||
if (shouldCancelCreatedJob) {
|
||
void cancelPMFlowLaunchDraftJob(job.job_id).catch(() => undefined);
|
||
return;
|
||
}
|
||
applyJobResult(job);
|
||
})
|
||
.catch((error) => {
|
||
const normalized = normalizeApiError(error, "ai_generation_failed");
|
||
if (normalized.kind === "auth_required" || normalized.kind === "session_expired") {
|
||
saveProjectLaunchInput({ ...snapshot.input, text: nextInput });
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: normalized.message,
|
||
failureKind: normalized.kind,
|
||
}));
|
||
});
|
||
}, [applyJobResult, snapshot.draftId, snapshot.input]);
|
||
|
||
const regenerateDraft = useCallback(() => {
|
||
if (snapshot.dataSourceMode !== "api" || !snapshot.draftId) {
|
||
startGeneration();
|
||
return;
|
||
}
|
||
const input = snapshot.input.text.trim();
|
||
const generationStartedAt = Date.now();
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "ai_working",
|
||
activeStepIndex: 0,
|
||
visibleOperationCount: 1,
|
||
paused: false,
|
||
elapsedSeconds: 0,
|
||
generationStartedAt,
|
||
generationFinishedAt: null,
|
||
inputUpdatedAt: current.inputUpdatedAt || generationStartedAt,
|
||
generationJobId: null,
|
||
generationRevisionId: null,
|
||
generationJobStatus: "queued",
|
||
generationForegroundWaitSeconds: null,
|
||
generationJobTimeoutSeconds: null,
|
||
generationLargeInputPolicy: null,
|
||
generationChangedFields: [],
|
||
errorMessage: null,
|
||
failureKind: null,
|
||
systemStatus: { ...current.systemStatus, message: "正在重新生成一版可比较的项目草案", updatedAt: "刚刚" },
|
||
}));
|
||
if (!getAuthToken()) {
|
||
saveProjectLaunchInput(snapshot.input);
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: "需要登录后继续。你的输入已保留,登录后可以继续重新生成执行草案。",
|
||
failureKind: "auth_required",
|
||
}));
|
||
return;
|
||
}
|
||
const payload = {
|
||
input: input || "请基于当前创意重新生成一版项目草案",
|
||
input_type: "text" as const,
|
||
attachments: buildLaunchDraftAttachments(snapshot.input),
|
||
source: "web",
|
||
mode: "regenerate" as const,
|
||
revision_note: "请基于同一创意重新生成一版可比较的替代草案,避免复刻上一版的标题、需求、任务和风险排序。",
|
||
parent_draft_id: snapshot.draftId,
|
||
};
|
||
void revisePMFlowLaunchDraft(snapshot.draftId, payload)
|
||
.then((job) => {
|
||
const shouldCancelCreatedJob = latestSnapshotRef.current.paused;
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
generationJobId: job.job_id,
|
||
generationRevisionId: job.revision_id,
|
||
generationJobStatus: job.status,
|
||
generationForegroundWaitSeconds: job.runtime_policy?.foreground_wait_seconds ?? current.generationForegroundWaitSeconds,
|
||
generationJobTimeoutSeconds: job.runtime_policy?.job_timeout_seconds ?? current.generationJobTimeoutSeconds,
|
||
generationLargeInputPolicy: job.runtime_policy?.large_input_policy ?? current.generationLargeInputPolicy,
|
||
systemStatus: {
|
||
...current.systemStatus,
|
||
message: current.paused ? "重新生成已暂停,当前任务会被取消" : "重新生成任务已创建,正在等待 AI 返回",
|
||
updatedAt: "刚刚",
|
||
},
|
||
}));
|
||
if (shouldCancelCreatedJob) {
|
||
void cancelPMFlowLaunchDraftJob(job.job_id).catch(() => undefined);
|
||
return;
|
||
}
|
||
applyJobResult(job);
|
||
})
|
||
.catch((error) => {
|
||
const normalized = normalizeApiError(error, "ai_generation_failed");
|
||
if (normalized.kind === "auth_required" || normalized.kind === "session_expired") {
|
||
saveProjectLaunchInput(snapshot.input);
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: normalized.message,
|
||
failureKind: normalized.kind,
|
||
}));
|
||
});
|
||
}, [applyJobResult, snapshot.dataSourceMode, snapshot.draftId, snapshot.input, startGeneration]);
|
||
|
||
const returnToInput = useCallback(() => {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "intent_input",
|
||
paused: false,
|
||
activeStepIndex: 0,
|
||
visibleOperationCount: 0,
|
||
generationStartedAt: null,
|
||
errorMessage: null,
|
||
failureKind: null,
|
||
systemStatus: { ...current.systemStatus, message: "系统已保存输入", updatedAt: "刚刚" },
|
||
}));
|
||
}, []);
|
||
|
||
const openDraftEdit = useCallback(() => {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "draft_edit",
|
||
editingDraft: cloneDraft(current.draft),
|
||
}));
|
||
}, []);
|
||
|
||
const updateEditingDraft = useCallback((draft: ProjectDraft) => {
|
||
setSnapshot((current) => ({ ...current, editingDraft: draft }));
|
||
}, []);
|
||
|
||
const saveDraft = useCallback(() => {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "human_review",
|
||
draft: cloneDraft(current.editingDraft),
|
||
systemStatus: { ...current.systemStatus, message: "系统已自动保存草稿", updatedAt: "刚刚" },
|
||
}));
|
||
}, []);
|
||
|
||
const cancelDraftEdit = useCallback(() => {
|
||
setSnapshot((current) => ({ ...current, stage: "human_review", editingDraft: cloneDraft(current.draft) }));
|
||
}, []);
|
||
|
||
const saveIdea = useCallback(() => {
|
||
const input = snapshot.input.text.trim();
|
||
if (!input || snapshot.savingIdea) return;
|
||
if (snapshot.dataSourceMode === "demo") {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "intent_input",
|
||
savedIdea: true,
|
||
generationStartedAt: null,
|
||
savedIdeaError: null,
|
||
systemStatus: { ...current.systemStatus, message: "已暂存为想法", updatedAt: "刚刚" },
|
||
}));
|
||
return;
|
||
}
|
||
if (!getAuthToken()) {
|
||
saveProjectLaunchInput(snapshot.input);
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: "需要登录后继续。你的输入已保留,登录后可以继续保存为暂存想法。",
|
||
failureKind: "auth_required",
|
||
savedIdeaError: null,
|
||
}));
|
||
return;
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
savingIdea: true,
|
||
savedIdeaError: null,
|
||
systemStatus: { ...current.systemStatus, message: "正在保存暂存想法", updatedAt: "刚刚" },
|
||
}));
|
||
void spaceApi
|
||
.createSavedIdea({
|
||
raw_text: input,
|
||
source_type: "project_launch",
|
||
input_mode: snapshot.input.mode,
|
||
attachments: buildLaunchDraftAttachments(snapshot.input),
|
||
})
|
||
.then((idea) => {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "intent_input",
|
||
savingIdea: false,
|
||
savedIdea: true,
|
||
generationStartedAt: null,
|
||
savedIdeaId: idea.id,
|
||
savedIdeaHref: "/space#saved-ideas",
|
||
savedIdeaError: null,
|
||
systemStatus: { ...current.systemStatus, message: "已保存到暂存想法", updatedAt: "刚刚" },
|
||
}));
|
||
})
|
||
.catch((error) => {
|
||
const normalized = normalizeApiError(error, "system_failed");
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
savingIdea: false,
|
||
savedIdea: false,
|
||
savedIdeaError: normalized.message,
|
||
systemStatus: { ...current.systemStatus, message: "暂存失败,输入已保留", updatedAt: "刚刚" },
|
||
}));
|
||
});
|
||
}, [snapshot.dataSourceMode, snapshot.input, snapshot.savingIdea]);
|
||
|
||
const createProject = useCallback(() => {
|
||
if (snapshot.creatingProject) return;
|
||
if (snapshot.dataSourceMode === "api") {
|
||
if (!snapshot.draftId) {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: "缺少可确认的项目草案,请重新生成。",
|
||
failureKind: "input_invalid",
|
||
}));
|
||
return;
|
||
}
|
||
if (!getAuthToken()) {
|
||
saveProjectLaunchInput(snapshot.input);
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
generationStartedAt: null,
|
||
errorMessage: "需要登录后继续。你的输入已保留,登录后可以继续创建项目。",
|
||
failureKind: "auth_required",
|
||
}));
|
||
return;
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
creatingProject: true,
|
||
systemStatus: { ...current.systemStatus, message: "正在创建真实项目", updatedAt: "刚刚" },
|
||
errorMessage: null,
|
||
failureKind: null,
|
||
}));
|
||
void confirmPMFlowLaunchDraft(snapshot.draftId, {
|
||
edited_draft: mapProjectDraftToEditedPayload(snapshot.draft, snapshot.rawDraft),
|
||
confirm: true,
|
||
})
|
||
.then((payload) => {
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "creation_success",
|
||
activeStepIndex: terminalStepIndex,
|
||
visibleOperationCount: aiOperationItemsMock.length,
|
||
paused: false,
|
||
creatingProject: false,
|
||
generationStartedAt: null,
|
||
redirectUrl: payload.redirect_url,
|
||
warnings: payload.warnings || [],
|
||
systemStatus: { ...current.systemStatus, message: "真实项目已创建", updatedAt: "刚刚" },
|
||
}));
|
||
})
|
||
.catch((error) => {
|
||
const normalized = normalizeApiError(error, "system_failed");
|
||
if (normalized.kind === "auth_required" || normalized.kind === "session_expired") {
|
||
saveProjectLaunchInput(snapshot.input);
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "generation_failed",
|
||
creatingProject: false,
|
||
generationStartedAt: null,
|
||
errorMessage: normalized.message,
|
||
failureKind: normalized.kind,
|
||
}));
|
||
});
|
||
return;
|
||
}
|
||
setSnapshot((current) => ({
|
||
...current,
|
||
stage: "creation_success",
|
||
activeStepIndex: terminalStepIndex,
|
||
visibleOperationCount: aiOperationItemsMock.length,
|
||
paused: false,
|
||
creatingProject: false,
|
||
generationStartedAt: null,
|
||
redirectUrl: "/command-center",
|
||
}));
|
||
}, [snapshot.creatingProject, snapshot.dataSourceMode, snapshot.draft, snapshot.draftId, snapshot.input, snapshot.rawDraft]);
|
||
|
||
return {
|
||
snapshot,
|
||
steps,
|
||
operations,
|
||
updateInput,
|
||
setInputMode,
|
||
startGeneration,
|
||
togglePause,
|
||
supplementInput,
|
||
regenerateDraft,
|
||
returnToInput,
|
||
openDraftEdit,
|
||
updateEditingDraft,
|
||
saveDraft,
|
||
cancelDraftEdit,
|
||
saveIdea,
|
||
createProject,
|
||
};
|
||
}
|