PMFlow/frontend/src/features/project-launch/useProjectLaunchState.ts

1187 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}