2fa967a93a
删除 agent-swarm-test-chat.mjs 的 --plan 模式、自动 GDD 审批回路与 planning 产物检查 删除根与应用 package.json 的 test:plan / test:plan:manual / agc:test:plan* 四条脚本 同步删除 agentSwarmTestEntry.test.ts 中只覆盖 V1 审批回路与 --plan 参数的用例 GddApprovalCard.tsx 注释改指 planning_gdd_model.rs 的现行路径权威定义 立项策划Agent(Fast GDD)方案文档头部标注已退役,仅作历史推导记录 策划会话 Runtime V2 方案文档状态更新为 P5 已完成并记录源码删除执行清单 Provider 兼容性缺陷文档的缺陷 4 标注相关代码已随 V1 退役删除 decision-log 新增 2026-09-08 策划 V1 链路源码整体退役决策记录
569 lines
18 KiB
TypeScript
569 lines
18 KiB
TypeScript
import { useState } from 'react';
|
||
|
||
import { resolveTauriInvoke } from '../../app/tauri';
|
||
import type {
|
||
PlanGddDecisionAction,
|
||
PlanGddStateViewV1,
|
||
} from '../../app/types';
|
||
|
||
/**
|
||
* 立项策划唯一的产品产物,由策划会话提交与审批回执渲染到项目内。
|
||
*
|
||
* 权威定义在 Rust 侧 `planning_gdd_model.rs` 的 `PLAN_FAST_GDD_PATH`,那里同时管着渲染
|
||
* 落盘和写入守卫。跨语言没有共享常量的通道,这里只能重复一份;改路径时两处要一起动。
|
||
*/
|
||
const PLAN_FAST_GDD_RELATIVE_PATH = 'game/fast_gdd.md';
|
||
|
||
/**
|
||
* 拼出交付文件的绝对路径。
|
||
*
|
||
* 用户要拿这行去资源管理器里找文件,所以跟随项目路径本身的分隔符:Windows 下项目
|
||
* 路径是反斜杠,混排出来的 `C:\...\project/game/fast_gdd.md` 虽然能用,但复制到
|
||
* 地址栏之外的地方就不像一个路径了。
|
||
*/
|
||
function planGddMarkdownDisplayPath(projectPath: string) {
|
||
const trimmed = projectPath.trim().replace(/[\\/]+$/u, '');
|
||
if (!trimmed) {
|
||
return PLAN_FAST_GDD_RELATIVE_PATH;
|
||
}
|
||
const separator = trimmed.includes('\\') ? '\\' : '/';
|
||
return `${trimmed}${separator}${PLAN_FAST_GDD_RELATIVE_PATH.split('/').join(separator)}`;
|
||
}
|
||
|
||
type GddApprovalCardProps = {
|
||
state: PlanGddStateViewV1 | null;
|
||
hydrateBusy: boolean;
|
||
decisionBusy: boolean;
|
||
error: string | null;
|
||
onRefresh: () => void;
|
||
onDecision: (
|
||
action: PlanGddDecisionAction,
|
||
comment: string | null,
|
||
) => Promise<void>;
|
||
onMakeGame?: () => Promise<void>;
|
||
};
|
||
|
||
const stateLabels: Record<PlanGddStateViewV1['state'], string> = {
|
||
not_started: '未开始',
|
||
draft: '策划中',
|
||
ready_for_approval: '待审批',
|
||
revision_requested: '待修订',
|
||
approved: '已批准',
|
||
rejected: '已退回',
|
||
};
|
||
|
||
function stageProgressVisible(
|
||
state: PlanGddStateViewV1 | null,
|
||
active: boolean,
|
||
) {
|
||
return Boolean(state && (active || state.state !== 'not_started'));
|
||
}
|
||
|
||
function approvalCardVisible(state: PlanGddStateViewV1 | null) {
|
||
return Boolean(
|
||
state?.displayGdd && (state.pendingApproval || state.recoveryPending),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 策划区的外壳:阶段进度是它的标题栏,审批卡是它的正文。
|
||
*
|
||
* 两者原本各画一个带边框的盒子,叠在一起时「立项策划 / 状态」在标题栏和卡头各出现
|
||
* 一次。合成一个面之后卡头只剩游戏标题和一句话,状态与版本只在标题栏画一遍。两个
|
||
* 挂载点都从这里进,不再各自拼装。
|
||
*/
|
||
export function PlanGddSurface({
|
||
state,
|
||
active = false,
|
||
projectPath,
|
||
hydrateBusy,
|
||
decisionBusy,
|
||
error,
|
||
onRefresh,
|
||
onDecision,
|
||
onMakeGame,
|
||
}: GddApprovalCardProps & { active?: boolean; projectPath: string }) {
|
||
const showProgress = stageProgressVisible(state, active);
|
||
const showCard = approvalCardVisible(state);
|
||
if (!showProgress && !showCard) {
|
||
return null;
|
||
}
|
||
return (
|
||
<section
|
||
className={`plan-gdd-surface${showCard ? ' plan-gdd-surface--with-card' : ''}`}
|
||
aria-label="立项策划"
|
||
>
|
||
{showProgress ? (
|
||
<PlanGddStageProgress
|
||
state={state}
|
||
active={active}
|
||
projectPath={projectPath}
|
||
onMakeGame={onMakeGame}
|
||
/>
|
||
) : null}
|
||
{showCard ? (
|
||
<GddApprovalCard
|
||
state={state}
|
||
hydrateBusy={hydrateBusy}
|
||
decisionBusy={decisionBusy}
|
||
error={error}
|
||
onRefresh={onRefresh}
|
||
onDecision={onDecision}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function PlanGddStageProgress({
|
||
state,
|
||
active = false,
|
||
projectPath = '',
|
||
onMakeGame,
|
||
}: {
|
||
state: PlanGddStateViewV1 | null;
|
||
active?: boolean;
|
||
projectPath?: string;
|
||
onMakeGame?: () => Promise<void>;
|
||
}) {
|
||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||
const [openError, setOpenError] = useState('');
|
||
const [opening, setOpening] = useState(false);
|
||
const [makingGame, setMakingGame] = useState(false);
|
||
const [makeGameError, setMakeGameError] = useState('');
|
||
if (!state || !stageProgressVisible(state, active)) {
|
||
return null;
|
||
}
|
||
const latestVersion =
|
||
state.displayGdd?.version ??
|
||
state.versions[state.versions.length - 1]?.gddRef.version ??
|
||
null;
|
||
const answeredRounds = state.session?.clarificationRound ?? 0;
|
||
const questionLimit = state.session?.questionLimit ?? 3;
|
||
const questionLimitLabel =
|
||
questionLimit === null ? '不限' : String(questionLimit);
|
||
// `clarificationRound` 是已完成的问询数;等待回答时加一表示当前正在展示的问题,
|
||
// 避免把「已答 N 个」误显示成当前第 N 轮。旧状态没有 questionLimit 时沿用旧 UI 的 3 轮
|
||
// 文案,V2 状态直接使用会话快照里的策略值。
|
||
const roundLabel = state.session?.awaitingAnswerFor
|
||
? `第 ${answeredRounds + 1} 轮 / 共 ${questionLimitLabel} 轮`
|
||
: `已完成 ${answeredRounds}/${questionLimitLabel} 轮澄清`;
|
||
const processingSeconds = (state.session?.accumulatedAgentMillis ?? 0) / 1000;
|
||
// 批准之后审批卡整张收掉,交付出口就落在这条标题栏上:GDD 的 Markdown 已经在项目
|
||
// 里(提交时渲染、审批回执重渲染带上 approved 头),这里只是把它指出来并交给系统
|
||
// 打开。恢复态不给出口——那时权威投影还没收敛,路径上的内容可能不是用户批的那版。
|
||
const deliveredGdd =
|
||
state.state === 'approved' && !state.recoveryPending
|
||
? state.displayGdd
|
||
: null;
|
||
const markdownPath = planGddMarkdownDisplayPath(projectPath);
|
||
return (
|
||
<section className="plan-gdd-stage-progress" aria-label="立项策划阶段进度">
|
||
<div className="plan-gdd-stage-progress__header">
|
||
<strong>立项策划</strong>
|
||
<span>{stateLabels[state.state]}</span>
|
||
</div>
|
||
<div className="plan-gdd-stage-progress__meta">
|
||
<span>{roundLabel}</span>
|
||
<span>
|
||
{latestVersion === null
|
||
? '当前版本:草稿'
|
||
: `当前版本:v${latestVersion}`}
|
||
</span>
|
||
{processingSeconds > 0 ? (
|
||
<span>{`处理耗时:${processingSeconds.toFixed(1)} 秒`}</span>
|
||
) : null}
|
||
</div>
|
||
{deliveredGdd ? (
|
||
<div
|
||
className="plan-gdd-stage-progress__delivery"
|
||
aria-label="GDD 交付"
|
||
>
|
||
<code title={markdownPath}>{markdownPath}</code>
|
||
<div className="plan-gdd-stage-progress__delivery-actions">
|
||
<button type="button" onClick={() => setDetailsOpen(true)}>
|
||
查看 GDD 正文
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={opening || !projectPath.trim()}
|
||
onClick={() => {
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke) {
|
||
setOpenError('当前环境不支持打开本地文件');
|
||
return;
|
||
}
|
||
setOpenError('');
|
||
setOpening(true);
|
||
void invoke('open_local_project_plan_gdd_markdown', {
|
||
projectPath,
|
||
})
|
||
.catch((error: unknown) =>
|
||
setOpenError(
|
||
error instanceof Error ? error.message : String(error),
|
||
),
|
||
)
|
||
.finally(() => setOpening(false));
|
||
}}
|
||
>
|
||
{opening ? '正在打开' : '打开文件'}
|
||
</button>
|
||
{onMakeGame ? (
|
||
<button
|
||
type="button"
|
||
disabled={opening || makingGame || !projectPath.trim()}
|
||
onClick={() => {
|
||
setMakeGameError('');
|
||
setMakingGame(true);
|
||
void onMakeGame()
|
||
.catch((error: unknown) =>
|
||
setMakeGameError(
|
||
error instanceof Error ? error.message : String(error),
|
||
),
|
||
)
|
||
.finally(() => setMakingGame(false));
|
||
}}
|
||
>
|
||
{makingGame ? '正在启动' : '做成游戏'}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{openError ? (
|
||
<small
|
||
className="plan-gdd-stage-progress__delivery-error"
|
||
role="alert"
|
||
>
|
||
{openError}
|
||
</small>
|
||
) : null}
|
||
{makeGameError ? (
|
||
<small
|
||
className="plan-gdd-stage-progress__delivery-error"
|
||
role="alert"
|
||
>
|
||
{makeGameError}
|
||
</small>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{detailsOpen && deliveredGdd ? (
|
||
<GddDetailsDialog
|
||
gdd={deliveredGdd}
|
||
onClose={() => setDetailsOpen(false)}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const decisionStateLabels = {
|
||
confirmed: '已确认',
|
||
assumption_pending: 'Agent 推断,待确认',
|
||
default_pending: '待确认默认项',
|
||
prototype_pending: '待原型验证',
|
||
};
|
||
|
||
const visibleGddSections = (
|
||
gdd: NonNullable<PlanGddStateViewV1['displayGdd']>,
|
||
) => [
|
||
{
|
||
title: '类型与美术',
|
||
content: [
|
||
`类型:${gdd.game.genre.primary}${gdd.game.genre.fusion ? ` / ${gdd.game.genre.fusion}` : ''}`,
|
||
`视觉:${gdd.game.artStyle.visualType}`,
|
||
`关键词:${gdd.game.artStyle.keywords.join('、')}`,
|
||
`氛围:${gdd.game.artStyle.moodAndColor}`,
|
||
`MVP 美术边界:${gdd.game.artStyle.mvpArtBoundary}`,
|
||
].join('\n'),
|
||
},
|
||
{
|
||
title: '游戏支柱',
|
||
content: gdd.game.pillars
|
||
.map(
|
||
(pillar) => `${pillar.name}:${pillar.playerFeel};${pillar.mechanism}`,
|
||
)
|
||
.join('\n'),
|
||
},
|
||
{ title: '核心循环', content: gdd.game.coreLoop.join(' → ') },
|
||
{
|
||
title: '目标用户',
|
||
content: [
|
||
`核心用户:${gdd.game.targetUsers.coreUsers}`,
|
||
`偏好:${gdd.game.targetUsers.preferences}`,
|
||
`单局时长:${gdd.game.targetUsers.sessionLength}`,
|
||
`参考作品:${gdd.game.targetUsers.referenceGames.join('、') || '无'}`,
|
||
].join('\n'),
|
||
},
|
||
{
|
||
title: '平台事实',
|
||
content: [
|
||
`运行时:${gdd.game.platformFacts.runtime}`,
|
||
`视口:${gdd.game.platformFacts.viewports.join('、')}`,
|
||
`输入:${gdd.game.platformFacts.inputs.join('、')}`,
|
||
`预览:${gdd.game.platformFacts.preview}`,
|
||
].join('\n'),
|
||
},
|
||
{
|
||
title: 'MVP 系统',
|
||
content: gdd.game.mvpSystems
|
||
.map(
|
||
(system) =>
|
||
`${system.system}:${system.minimalFunction}\n为什么需要:${system.whyRequired}\n验证:${system.verifyMethod}`,
|
||
)
|
||
.join('\n'),
|
||
},
|
||
{ title: '暂不纳入', content: gdd.game.outOfScope.join('、') },
|
||
{
|
||
title: '创作者提示',
|
||
content: [
|
||
`先做:${gdd.game.creatorTips.doFirst}`,
|
||
`暂缓:${gdd.game.creatorTips.deferForNow}`,
|
||
`验证:${gdd.game.creatorTips.howToVerify}`,
|
||
`扩展条件:${gdd.game.creatorTips.expandWhen}`,
|
||
].join('\n'),
|
||
},
|
||
{
|
||
title: '原型验证项',
|
||
content: gdd.prototypeValidationItems
|
||
.map(
|
||
(item) =>
|
||
`${item.question}\n微型原型:${item.microPrototype}\n观察:${item.observation}\n通过标准:${item.passCriterion}`,
|
||
)
|
||
.join('\n'),
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Fast GDD 的正文视图。
|
||
*
|
||
* 审批时挂在审批卡上,批准之后审批卡收掉、改由阶段进度条那条交付行触发——同一份
|
||
* 弹层两处共用,用户在批准前后看到的是同一个正文。
|
||
*/
|
||
export function GddDetailsDialog({
|
||
gdd,
|
||
onClose,
|
||
}: {
|
||
gdd: NonNullable<PlanGddStateViewV1['displayGdd']>;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="gdd-approval-card__dialog-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
onClose();
|
||
}
|
||
}}
|
||
>
|
||
<section
|
||
className="gdd-approval-card__dialog gdd-approval-card__details"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="gdd-approval-details-title"
|
||
>
|
||
<header>
|
||
<h3 id="gdd-approval-details-title">{gdd.game.title}</h3>
|
||
<p>{gdd.game.oneLiner}</p>
|
||
</header>
|
||
{visibleGddSections(gdd)
|
||
.filter((section) => section.content)
|
||
.map((section) => (
|
||
<article key={section.title}>
|
||
<strong>{section.title}</strong>
|
||
<p>{section.content}</p>
|
||
</article>
|
||
))}
|
||
<small
|
||
className="gdd-approval-card__details-trace"
|
||
aria-label="GDD 版本与指纹"
|
||
>
|
||
{`Fast GDD v${gdd.version} · ${gdd.fingerprint}`}
|
||
</small>
|
||
<div className="gdd-approval-card__dialog-actions">
|
||
<button type="button" onClick={onClose}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function GddApprovalCard({
|
||
state,
|
||
hydrateBusy,
|
||
decisionBusy,
|
||
error,
|
||
onRefresh,
|
||
onDecision,
|
||
}: GddApprovalCardProps) {
|
||
const [commentAction, setCommentAction] = useState<Exclude<
|
||
PlanGddDecisionAction,
|
||
'approve'
|
||
> | null>(null);
|
||
const [comment, setComment] = useState('');
|
||
const [gddDetailsOpen, setGddDetailsOpen] = useState(false);
|
||
|
||
if (!state?.displayGdd || !approvalCardVisible(state)) {
|
||
return null;
|
||
}
|
||
|
||
const gdd = state.displayGdd;
|
||
const pending = state.pendingApproval;
|
||
const canDecide = Boolean(
|
||
pending && state.state === 'ready_for_approval' && !state.recoveryPending,
|
||
);
|
||
// `hydrateBusy` only describes an in-flight background read. Once a card is rendered, the
|
||
// loaded projection remains the actionable snapshot until it explicitly reports
|
||
// `recoveryPending`; otherwise a refresh racing the first render can transiently disable an
|
||
// already-open dialog before the user has had a chance to submit it.
|
||
const decisionDisabled = !canDecide || decisionBusy;
|
||
|
||
const submitComment = () => {
|
||
// 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮
|
||
// 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的,
|
||
// 所以提交口要自己再判一次,不能只靠按钮 disabled。
|
||
if (decisionDisabled || !commentAction || !comment.trim()) {
|
||
return;
|
||
}
|
||
void onDecision(commentAction, comment.trim())
|
||
.then(() => {
|
||
setCommentAction(null);
|
||
setComment('');
|
||
})
|
||
.catch(() => undefined);
|
||
};
|
||
|
||
return (
|
||
<section className="gdd-approval-card" aria-label="GDD 审批卡">
|
||
<header className="gdd-approval-card__header">
|
||
<h2>{gdd.game.title || `Fast GDD v${gdd.version}`}</h2>
|
||
<p>{gdd.game.oneLiner}</p>
|
||
</header>
|
||
|
||
{gdd.decisions.length > 0 ? (
|
||
<div className="gdd-approval-card__decisions" aria-label="决定状态">
|
||
{gdd.decisions.map((decision) => (
|
||
<article key={decision.id}>
|
||
<strong>{decision.topic}</strong>
|
||
<span>{decisionStateLabels[decision.state]}</span>
|
||
<small>{decision.answerSummary}</small>
|
||
</article>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
className="gdd-approval-card__details-trigger"
|
||
onClick={() => setGddDetailsOpen(true)}
|
||
>
|
||
查看 GDD 正文
|
||
</button>
|
||
|
||
{state.recoveryPending ? (
|
||
<div className="gdd-approval-card__recovery" role="status">
|
||
<span>审批状态正在同步,请不要重复提交当前版本。</span>
|
||
<button type="button" disabled={hydrateBusy} onClick={onRefresh}>
|
||
{hydrateBusy ? '同步中' : '重试同步'}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="gdd-approval-card__error" role="alert">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
{pending ? (
|
||
<div className="gdd-approval-card__actions">
|
||
<button
|
||
type="button"
|
||
disabled={decisionDisabled}
|
||
onClick={() =>
|
||
void onDecision('approve', null).catch(() => undefined)
|
||
}
|
||
>
|
||
{decisionBusy ? '处理中' : `批准 v${pending.gddRef.version}`}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={decisionDisabled}
|
||
onClick={() => setCommentAction('revise')}
|
||
>
|
||
修改
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={decisionDisabled}
|
||
onClick={() => setCommentAction('reject')}
|
||
>
|
||
退回重做
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{commentAction ? (
|
||
<div
|
||
className="gdd-approval-card__dialog-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
setCommentAction(null);
|
||
setComment('');
|
||
}
|
||
}}
|
||
>
|
||
<section
|
||
className="gdd-approval-card__dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="gdd-approval-comment-title"
|
||
>
|
||
<h3 id="gdd-approval-comment-title">
|
||
{commentAction === 'revise' ? '填写修改意见' : '填写退回原因'}
|
||
</h3>
|
||
<textarea
|
||
autoFocus
|
||
rows={5}
|
||
value={comment}
|
||
placeholder="请输入原因"
|
||
onChange={(event) => setComment(event.currentTarget.value)}
|
||
/>
|
||
{!canDecide ? (
|
||
<p className="gdd-approval-card__dialog-hint" role="status">
|
||
审批状态正在同步,暂时不能提交决定。已输入的内容会保留。
|
||
</p>
|
||
) : null}
|
||
<div className="gdd-approval-card__dialog-actions">
|
||
<button
|
||
type="button"
|
||
disabled={decisionBusy}
|
||
onClick={() => {
|
||
setCommentAction(null);
|
||
setComment('');
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={decisionDisabled || !comment.trim()}
|
||
onClick={submitComment}
|
||
>
|
||
{decisionBusy ? '提交中' : '提交决定'}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{gddDetailsOpen ? (
|
||
<GddDetailsDialog gdd={gdd} onClose={() => setGddDetailsOpen(false)} />
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|