diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 7b34bc4f7..39f9450e0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -5,16 +5,16 @@ * 会卸载,但「读到了什么、有什么冲突、正在跑什么」必须留在面板上。 * * 三条边界写死在这里: - * - agent 调用只有用户显式点「适配 / 让 agent 修」才会发生,保存与导出**不触发任何** agent 调用; + * - agent 调用只有用户显式点「适配 / 让 agent 修 / 帮我填」才会发生,保存与导出**不触发任何** + * agent 调用; * - `hasScript` 永远来自宿主的现算结果,前端不自己读 `package.json`、也不缓存这个事实; * - 注册表指纹是写回时的同步细节,不进对外状态:它只在 `baselineRef` 里,且只有进编辑会话或 * 内容真的被采纳时才单独取一次。自动刷新([CONTENT_REFRESH_INTERVAL_MS])读的是不含指纹的 * 内容命令,所以「刷新」在类型上就动不了写回基线。 * - * 失败按 ADR 的两道口子分流(`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`、 - * `【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`):typed 变体里除了宿主侧事实故障 - * (`exportUnavailable`)都留在面板里就地消化;**宿主故障与认不出形状的拒绝原样抛出**,由全局 - * `unhandledrejection` 送进错误池,面板不把原始 message 当成业务提示糊给用户。 + * 失败分两道口子:typed 变体里除了宿主侧事实故障(`exportUnavailable`)都留在面板里就地消化; + * **宿主故障与认不出形状的拒绝原样抛出**,由全局 `unhandledrejection` 送进错误池,面板不把原始 + * message 当成业务提示糊给用户。 */ import { useCallback, useEffect, useRef, useState } from 'react'; @@ -22,37 +22,32 @@ import { resolveTauriInvoke } from '../../../../app/tauri'; import type { UserContentPart } from '../../chat/generated/UserContentPart'; import type { FormInvalid } from '../generated/FormInvalid'; import type { SaveConflict } from '../generated/SaveConflict'; -import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState'; import { - asXhsMinitoolFailure, + asXhsMinitoolBuildFailure, + asXhsMinitoolReadFailure, + asXhsMinitoolSaveFailure, readXhsMinitoolExport, readXhsMinitoolExportHash, runXhsMinitoolExportBuild, saveXhsMinitoolExportForm, } from './xhsMinitoolApi'; +import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure'; import { + classifyXhsMinitoolFailure, describeXhsMinitoolFailure, - type XhsMinitoolFailureNotice, + XhsMinitoolFailureRepairability, } from './xhsMinitoolFailure'; import { buildXhsMinitoolAdaptationInstruction, - buildXhsMinitoolRepairInstruction, - XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + buildXhsMinitoolErrorFixInstruction, + buildXhsMinitoolFormFillInstruction, } from './xhsMinitoolInstruction'; import { describeOutputTail } from './xhsMinitoolOutputTail'; export type XhsMinitoolExportState = ReturnType; -/** - * 一次失败的完整现场:给用户看的说法,加上原始载荷(「让 agent 修」要用它拼指令)。 - */ -export type XhsMinitoolFailureState = { - notice: XhsMinitoolFailureNotice; - error: XHSMiniToolExportError; -}; - /** 输入停下多久之后自动保存:够长到不打断打字,够短到用户切走的时候不丢字。 */ const AUTOSAVE_DELAY_MS = 600; /** @@ -62,13 +57,8 @@ const AUTOSAVE_DELAY_MS = 600; * 每次只读内容(不含指纹),而且只在用户手上没有未保存输入时才采纳表单,所以轮询不会吃掉输入。 */ const CONTENT_REFRESH_INTERVAL_MS = 2000; -const NO_HOST_NOTICE = - '需要在客户端内打开导出面板,宿主才能读写注册表并运行构建脚本'; - -/** 把结构化失败收成现场:给用户看的说法 + 原始载荷。 */ -function failureState(error: XHSMiniToolExportError): XhsMinitoolFailureState { - return { notice: describeXhsMinitoolFailure(error), error }; -} +const NO_HOST_NOTICE = '要在客户端里打开导出面板,才能保存信息并生成代码包'; +const CONTENT_UNAVAILABLE_NOTICE = '没能读到导出信息,正在自动重试…'; /** 三个字段就是全部内容,逐字段比即可,不必引入深比较。 */ function sameForm( @@ -98,16 +88,39 @@ export function useXhsMinitoolExport({ const [hasScript, setHasScript] = useState(false); const [fieldError, setFieldError] = useState(null); const [conflict, setConflict] = useState(null); - const [failure, setFailure] = useState(null); + const [failure, setFailure] = useState(null); const [notice, setNotice] = useState(''); const [loading, setLoading] = useState(false); + /** + * 首次读取是否已经落定(无论成功、结构化失败还是认不出形状的拒绝)。 + * + * 界面不能拿「`form` 还是 null」当「正在读」的判据:读失败时 `form` 也一直是 null,那样面板会 + * 永远停在「正在读取导出信息…」上,用户既看不到失败原因、也不知道它在不在动。 + */ + const [readSettled, setReadSettled] = useState(false); const [running, setRunning] = useState(false); + /** + * 这次会话里是否已经成功打出过分区:只有它为真,面板才给「下载代码包」。 + * + * 宿主不报告磁盘上有没有产物,所以初值只能是 `false`(哪怕上一轮会话刚打过);成功构建置真、 + * 构建失败置假——上次成功的那份此刻可能已经被失败的那次覆盖或删掉,不给一个下不了的下载按钮。 + * 用户改表单不影响它:磁盘上那份仍是可下载的,只是内容旧了。 + */ + const [artifactReady, setArtifactReady] = useState(false); const [dirty, setDirty] = useState(false); + /** + * 「还没适配」的阻断提示被用户处理过一次就收起:无论选了「发给 code agent」还是「我自己来」, + * 都不许每 2 秒重读回来时再弹一次。脚本真的出现过后重置——它要是再消失,提示要能回来。 + * 这是纯界面状态,但它必须跨 tab 页卸载存活,所以和写回基线一样挂在面板根的这个 hook 上。 + */ + const [adaptPromptDismissed, setAdaptPromptDismissed] = useState(false); /** * 写回基线:注册表指纹,保存时当 `baseHash`。**不进对外状态**——界面没有它,也只有保存路径 * 读它。 */ const baselineRef = useRef(''); + /** 首次读取是否已经落定。`loading` 只覆盖这一段,自动重读不许再翻它。 */ + const initialReadSettledRef = useRef(false); /** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */ const adoptableRef = useRef(false); /** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */ @@ -122,6 +135,11 @@ export function useXhsMinitoolExport({ adoptableRef.current = !dirty && !conflict && !fieldError; }, [dirty, conflict, fieldError]); + // 脚本出现之后就把提示重新武装:它要是又被删掉,用户该再看到一次阻断提示。 + useEffect(() => { + if (hasScript) setAdaptPromptDismissed(false); + }, [hasScript]); + /** * 真故障(认不出形状的拒绝、宿主侧事实故障)原样抛出,交给全局 `unhandledrejection` 结算。 * @@ -147,45 +165,71 @@ export function useXhsMinitoolExport({ * `hasScript`,绝不覆盖正在打的字。 * * 内容真的变了才去取指纹:指纹与内容必须配套,取早了会把外部改动认成自己的基线。 + * + * 返回值是**这次现算出来的**「有没有脚本」:`true` / `false` 是宿主的当场结论,`null` 是这次 + * 没读到(没有项目路径、没有宿主、注册表坏、认不出形状的拒绝)。「打包」要用这个当场结论决定 + * 是接着构建,还是掉头去叫陶泥儿适配——所以读不到时不能谎报成 `false`。 */ - const refresh = useCallback(async (): Promise => { - if (!projectPath) return; + const refresh = useCallback(async (): Promise => { + if (!projectPath) return null; const invoke = resolveTauriInvoke(); if (!invoke) { setNotice(NO_HOST_NOTICE); - return; + return null; } - setLoading(true); + // 只有首次读取才翻 `loading`:自动重读每 2 秒一次,每次都翻一下这个状态,界面上的 + // 按钮与卡片就会跟着一闪一闪(未适配提示、按钮行的「适配」都以它为判据之一)。 + const isInitialRead = !initialReadSettledRef.current; + if (isInitialRead) setLoading(true); try { const content: XHSMiniToolExportState = await readXhsMinitoolExport( invoke, projectPath, ); setHasScript(content.hasScript); - if (!adoptableRef.current || sameForm(formRef.current, content.form)) { - return; + // 首次读取无条件采纳:那时候用户不可能有未保存输入,采纳规则拦在这里只会让面板永远 + // 停在「正在读取」(`adoptableRef` 的初值是 false,它是给轮询用的)。 + if ( + !isInitialRead && + (!adoptableRef.current || sameForm(formRef.current, content.form)) + ) { + return content.hasScript; } setForm(content.form); setFieldError(null); setConflict(null); setFailure(null); + // 只撤掉「读不到」那一句:适配 / 打包的回执也是 `notice`,读到内容不等于用户读过了它, + // 每 2 秒重读一次就把回执擦掉会让回执根本来不及被看见。 + setNotice((previous) => + previous === CONTENT_UNAVAILABLE_NOTICE ? '' : previous, + ); baselineRef.current = await readXhsMinitoolExportHash( invoke, projectPath, ); + return content.hasScript; } catch (error) { - const structured = asXhsMinitoolFailure(error); + const structured = asXhsMinitoolReadFailure(error); if (!structured) { + // 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):面板留一句实话, + // 现场原样抛给错误池结算。没有这一句,面板会停在「正在读取」上装死。 + setNotice(CONTENT_UNAVAILABLE_NOTICE); throwToErrorPool(String(error), error); - return; + return null; } - setFailure(failureState(structured)); + setFailure(structured); // 宿主侧事实故障:先让面板显示现场,再抛进错误池(策略拒绝等预期拒绝不走这一步)。 if (structured.type === 'exportUnavailable') { throwToErrorPool(JSON.stringify(structured), error); } + return null; } finally { - setLoading(false); + if (isInitialRead) { + initialReadSettledRef.current = true; + setLoading(false); + setReadSettled(true); + } } }, [projectPath, throwToErrorPool]); @@ -200,13 +244,20 @@ export function useXhsMinitoolExport({ return () => window.clearInterval(timer); }, [enabled, refresh]); + /** + * 落盘一份表单,返回是否真的写成功。 + * + * 这是**唯一**做表单硬校验的地方(`validate_form` 在保存命令里):自动保存到点就调它,「打包」 + * 要落盘手上没保存的输入也调它。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等 + * 用户处理,不拿一份没通过校验的表单去构建。 + */ const persist = useCallback( - async (next: XHSMiniToolExportForm, baseHash: string): Promise => { - if (!projectPath) return; + async (next: XHSMiniToolExportForm, baseHash: string): Promise => { + if (!projectPath) return false; const invoke = resolveTauriInvoke(); if (!invoke) { setNotice(NO_HOST_NOTICE); - return; + return false; } try { baselineRef.current = await saveXhsMinitoolExportForm(invoke, { @@ -218,23 +269,25 @@ export function useXhsMinitoolExport({ setConflict(null); setFailure(null); setDirty(false); + return true; } catch (error) { // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 setDirty(false); - const structured = asXhsMinitoolFailure(error); + const structured = asXhsMinitoolSaveFailure(error); if (!structured) { throwToErrorPool(String(error), error); - return; + return false; } if (structured.type === 'formInvalid') setFieldError(structured); else if (structured.type === 'saveConflict') setConflict(structured); else { - setFailure(failureState(structured)); + setFailure(structured); if (structured.type === 'exportUnavailable') { throwToErrorPool(JSON.stringify(structured), error); } } + return false; } }, [projectPath, throwToErrorPool], @@ -272,69 +325,180 @@ export function useXhsMinitoolExport({ [conflict, persist], ); - /** 导出:每次都重新跑脚本、重新产出 zip,没有"上次的产物还能用"这条捷径。 */ - const runExport = useCallback(async (): Promise => { + /** + * 跑一次构建:每次都重新跑脚本、重新产出 zip,没有"上次的产物还能用"这条捷径。 + * + * 不碰 `running`:调用方决定这次构建是一次独立导出,还是「打包」里保存之后的收尾。 + */ + const buildOnce = useCallback(async (): Promise => { if (!projectPath) return; const invoke = resolveTauriInvoke(); if (!invoke) { setNotice(NO_HOST_NOTICE); return; } - setRunning(true); setNotice(''); setFailure(null); try { const result = await runXhsMinitoolExportBuild(invoke, projectPath); + setArtifactReady(true); // 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。 setNotice( - [ - `已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`, - describeOutputTail(result), - ] + ['代码包已重新生成,点「下载」保存到本地。', describeOutputTail(result)] .filter((line) => line.trim() !== '') .join('\n'), ); } catch (error) { - const structured = asXhsMinitoolFailure(error); + setArtifactReady(false); + const structured = asXhsMinitoolBuildFailure(error); if (!structured) { throwToErrorPool(String(error), error); return; } - setFailure(failureState(structured)); + setFailure(structured); if (structured.type === 'exportUnavailable') { throwToErrorPool(JSON.stringify(structured), error); } - } finally { - setRunning(false); } }, [projectPath, throwToErrorPool]); + /** 只重新构建:失败卡片上的「重试」走这里,不夹带保存。 */ + const runExport = useCallback(async (): Promise => { + if (!projectPath) return; + setRunning(true); + try { + await buildOnce(); + } finally { + setRunning(false); + } + }, [projectPath, buildOnce]); + + /** + * 打包:先**现场复算**「有没有脚本」,再冲掉没保存的输入,最后跑构建。 + * + * 「打包」永远可点(除了正在跑),因为「有脚本」是个当场才成立的事实:agent 可能刚把脚本写进 + * 项目、用户可能刚把上一份删掉。点一下就现算一次,比让用户先找别的地方核对再回来点便宜。 + * + * 没有脚本(或这次读不到)时不报错了事,而是把阻断弹窗重新武装起来——用户点「打包」的本意就是 + * 「我要一份代码包」,这条路走不通时该当场把「让陶泥儿帮你调通」这个出口递给他。 + * + * 这里不做任何表单校验——校验只发生在保存命令里(`persist`)。打包只关心「盘上的注册表是不是 + * 用户现在这份」:有未保存输入就先 flush,flush 回 `false`(字段不合法、撞上冲突、宿主故障) + * 就停在表单上让用户处理;冲突或字段红字还没解决也一样停住。构建本身只认盘上的文件。 + */ + const packageAll = useCallback(async (): Promise => { + if (!projectPath) return; + setRunning(true); + try { + const hasScriptNow = await refresh(); + if (hasScriptNow !== true) { + setAdaptPromptDismissed(false); + if (hasScriptNow === false) { + setNotice( + '项目还没配置好导出,先让陶泥儿帮你调通,之后就能直接打包了', + ); + } + return; + } + // 先 flush 自动保存:用户刚敲完就点打包时,注册表里可能还是上一份。 + if (dirty && form && !conflict && !fieldError) { + const flushed = await persist(form, baselineRef.current); + if (!flushed) return; + } + // 没解决的冲突 / 字段红字:盘上的内容和用户看到的不一致,先别打包。 + if (conflict || fieldError) return; + await buildOnce(); + } finally { + setRunning(false); + } + }, [ + projectPath, + refresh, + dirty, + form, + conflict, + fieldError, + persist, + buildOnce, + ]); + + /** + * 把一条指令当用户消息发给项目会话。 + * + * `sentNotice` 由调用方按「首次适配 / 失败修复」分别给:两条路径给 agent 的指令不同,给用户的 + * 回执也不该长一个样——失败修复那条必须把这次的失败原因复述出来,否则失败卡片收掉之后用户只剩 + * 一句「已交给 agent」。 + */ const sendToAgent = useCallback( - async (instruction: string): Promise => { + async (instruction: string, sentNotice: string): Promise => { const handedOver = await submitTurn([ { type: 'input_text', text: instruction }, ]); - setNotice( - handedOver - ? '已把任务交给 code agent,右侧会话里能看到它的进展' - : '这条任务没有发出去,原因在右侧会话里', - ); + setNotice(handedOver ? sentNotice : '这次没能发出去,原因在右侧会话里'); }, [submitTurn], ); - /** 一键适配:把首次适配指令发给同一个项目会话。 */ + /** 收起「还没适配」的阻断提示。用户选「我自己来」时走这里。 */ + const dismissAdaptPrompt = useCallback((): void => { + setAdaptPromptDismissed(true); + }, []); + + /** + * 「帮我填」:把手上没保存的输入先落盘,再让 agent 直接改注册表把表单三项填好。 + * + * 这个动作的同步链只有一条:盘上那份注册表文件。agent 照它改,宿主每 2 秒读它一次带回界面—— + * 所以我们没落盘的输入它看不见,而且 `dirty` 不清零时重读按规矩不覆盖表单,agent 写的新值也进 + * 不来。**先落盘不是附带效果,是这条链的前半截。** + * + * 落盘结果不挡这次交接:agent 正是来收拾这份表单的,把它拦在门外只剩用户自己改,所以不看 + * `persist` 的返回值——不合法、撞冲突的那些值随指令一起原样带给 agent。 + */ + const fillForm = useCallback(async (): Promise => { + if (!projectPath || !form) return; + // 冲突或字段红字挂着时不重复撞一次保存(自动保存此时也是停的),直接把现场交给 agent。 + if (dirty && !conflict && !fieldError) { + await persist(form, baselineRef.current); + } + await sendToAgent( + buildXhsMinitoolFormFillInstruction(), + '已让陶泥儿来填这三项,它改完会自动出现在上面', + ); + }, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]); + + /** 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 */ const adapt = useCallback((): void => { if (!projectPath) return; - void sendToAgent(buildXhsMinitoolAdaptationInstruction(projectPath)); + setAdaptPromptDismissed(true); + void sendToAgent( + buildXhsMinitoolAdaptationInstruction(), + '已让陶泥儿帮你调通导出,右侧会话里能看到它的进展', + ); }, [projectPath, sendToAgent]); - /** 按当前失败现场让 agent 修。失败卡片上的那颗按钮走这里。 */ + /** + * 按当前失败现场让 agent 修(error fix)。失败卡片上的那颗按钮走这里。 + * + * 只有 `CodeAgentRepairable` 才允许走这条路:冲突要用户在冲突面板上逐字段二选一、表单值不对要用户改、 + * 项目权限策略拉黑了 `command.exec` 是用户自己的配置(agent 受同一条策略约束)、宿主没跑起来改代码没用—— + * 这些叫 agent 进来它只会去改一份没问题可改的代码。 + * + * 用户回执带失败原因:卡片随即被撤掉,这句提示是面板上唯一还留着「刚才为什么开这一轮」的地方。 + */ const repair = useCallback((): void => { if (!projectPath || !failure) return; + if ( + classifyXhsMinitoolFailure(failure) !== + XhsMinitoolFailureRepairability.CodeAgentRepairable + ) { + return; + } + // 回执复述用户侧的说法;给 agent 的指令直接带原始错误,两者互不派生。 + const reason = describeXhsMinitoolFailure(failure); setFailure(null); void sendToAgent( - buildXhsMinitoolRepairInstruction(projectPath, failure.error), + buildXhsMinitoolErrorFixInstruction(failure), + `已让陶泥儿来修。\n出错原因:${reason}`, ); }, [projectPath, failure, sendToAgent]); @@ -342,7 +506,7 @@ export function useXhsMinitoolExport({ * 对外只有界面要用的东西:内容(表单、脚本判据)、三种待处理的现场、跑动状态。 * * 同步细节(写回基线、保存中、待保存)刻意不在这里:它们没有对应的界面,露出来只会诱使调用方 - * 拿它做判断。刷新也不是对外动作——重读内容由自动刷新和「导出」之后的现算负责。 + * 拿它做判断。刷新也不是对外动作——重读内容由自动刷新和「打包」之后的现算负责。 */ return { form, @@ -352,11 +516,17 @@ export function useXhsMinitoolExport({ failure, notice, loading, + readSettled, running, + artifactReady, + adaptPromptDismissed, updateField, resolveConflict, runExport, + packageAll, adapt, repair, + fillForm, + dismissAdaptPrompt, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx new file mode 100644 index 000000000..c02fc48d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx @@ -0,0 +1,48 @@ +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; +import { PaneButton } from './PaneButton'; + +/** + * 「还没配置好导出」的阻断提示。 + * + * 判据只有一个:宿主现算的 `hasScript` 为假。这件事只能由陶泥儿在项目里实验完成(用哪版 vite + * 配置、入口在哪、怎么调打包脚本都由它定),所以用户只有两条路:现在就让陶泥儿调通,或者自己处理。 + * + * 是阻断层而不是卡片:不点一个选择就不放行,避免用户在不清楚状况时直接点「打包」。两个选择都必须 + * 显式给出,背景点击与 Esc 不生效。 + * + * 文案不出现脚本名、注册表、zip 这些实现词——用户要的是「怎么才能打包」。 + */ +export function AdaptPromptDialog({ + open, + onSendToAgent, + onDoItMyself, +}: { + open: boolean; + onSendToAgent: () => void; + onDoItMyself: () => void; +}) { + return ( + +

+ 这个项目还没有配置好导出 +

+

+ 要让陶泥儿先在项目里帮你调通一次导出流程(用哪套配置、入口在哪、怎么打包都由它试出来)。 + 调通之后,每次点「打包」就能直接生成代码包。 +

+
+ 我自己来 + + 让陶泥儿帮我调通 + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 3aa7cceb7..e323992bc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -2,22 +2,24 @@ import { useState } from 'react'; import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; -import { - XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, - XHS_MINITOOL_SCRIPT_NAME, -} from '../../state/xhsMinitoolInstruction'; +import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction'; +import { AdaptPromptDialog } from './AdaptPromptDialog'; import { ConflictCard } from './ConflictCard'; -import { CopyButton } from './CopyButton'; import { FailureCard } from './FailureCard'; import { FormCard } from './FormCard'; import { PaneButton } from './PaneButton'; /** - * 小红书工作台左半:平台上传表单 + 交付产物 + 两颗显式按钮。 + * 小红书工作台左半:平台上传表单 + 小工具代码包 + 「打包」。 * - * 两颗按钮各管一件事,**都没有自动跑**:适配(叫 code agent 进项目做首次实验)、导出(每次重新 - * 构建)。其余动作是自动的,且都不碰 agent:表单停手后自动落盘、内容与脚本判据按固定间隔重读 - * (所以没有刷新按钮)——判断结果直接反映在按钮的可用状态上,不用用户去核对一个同步状态。 + * 「打包」(先校验落盘表单、再跑构建产出 zip,一次做完,小红书红)挂在「小工具代码包」段落里, + * 打成功了同一行才多出「下载代码包」——没打过就不给一个下不了的按钮。适配入口不在按钮行里—— + * 未配置好导出时由阻断弹窗(`AdaptPromptDialog`)给出「让陶泥儿帮我调通 / 我自己来」两个选择, + * 失败现场由失败卡片的 `adapt` 入口兜底。其余动作是自动的,且都不碰 agent:表单停手后自动落盘、 + * 内容与脚本判据按固定间隔重读(所以没有刷新按钮)。 + * + * 「还没配置好导出」不再是一张常驻卡片,而是阻断弹窗:重读每 2 秒一次,卡片会跟着重读闪,弹窗则 + * 只在用户真的需要做选择时出现一次。可见文案说用户的话,不出现脚本名、注册表、zip 这些实现词。 */ export function ArtifactsPane({ minitool, @@ -29,18 +31,21 @@ export function ArtifactsPane({ const [downloadNotice, setDownloadNotice] = useState(''); const { form, conflict } = minitool; /** - * 未适配:那颗按钮搬进提示卡片,说明和入口放一起,用户不用在别处找。 - * - * 判据带上 `form`:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目 - * 下「还没适配」的结论——那种情况只该显示读取中或失败现场。 + * 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下 + * 「还没适配」的结论——那种情况只该显示读取中或失败现场。 * * 判据带上 `failure`:失败卡片带着宿主的现场和同一颗按钮,两张卡一起出现就是同一件事两个入口。 */ - const missingScript = + const needsAdaptation = !minitool.loading && form !== null && !minitool.hasScript && minitool.failure === null; + /** + * 弹窗只弹一次:用户选过「发送提示词 / 我自己来」之后,重读回来仍是没有脚本也不许再拦。 + * 选择留在面板根的 hook 上,切 tab 页再回来不会重新弹。 + */ + const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed; const downloadIcon = (): void => { if (!projectPath || !form) return; @@ -48,7 +53,7 @@ export function ArtifactsPane({ projectPath, relativePath: form.iconPath.trim(), fallbackFileName: 'icon.png', - title: '保存 icon 文件', + title: '保存图标文件', }).then(setDownloadNotice); }; @@ -58,113 +63,104 @@ export function ArtifactsPane({ projectPath, relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, fallbackFileName: 'xhs-minitool.zip', - title: '保存代码 zip', + title: '保存代码包', }).then(setDownloadNotice); }; return (
-
- {missingScript ? null : ( - - 适配 - +
+ {form ? ( + void minitool.fillForm()} + /> + ) : !minitool.readSettled ? ( +

正在读取导出信息…

+ ) : // 读失败时上面不重复一句话:失败卡片拿着宿主的现场和下一步。 + minitool.failure ? null : ( +

+ {minitool.notice || '没能读到导出信息,正在自动重试…'} +

)} - void minitool.runExport()} + +
- {minitool.running ? '导出中…' : '导出'} - +

小工具代码包

+
+ {/* + 「打包」不因为有脚本判据没到位就变灰:有没有脚本是**点下去那一刻**才成立的事实 + (agent 可能刚写完、用户可能刚删掉)。灰着按钮等于把「能不能开始」的判断推给用户, + 而他手上没有任何办法去核对。点下去先现算一次,没有就当场把适配出口递过去。 + */} + void minitool.packageAll()} + > + {minitool.running ? '打包中…' : '打包'} + + {/* 下载入口只在打包成功之后出现:宿主不报告磁盘上有没有产物,没打过就不给一个下不了的按钮。 */} + {minitool.artifactReady ? ( + + 下载代码包 + + ) : null} +
+
+ + {conflict && form ? ( + + ) : null} + + {minitool.failure ? ( + void minitool.runExport()} + /> + ) : null} + + {downloadNotice || minitool.notice ? ( +

+ {downloadNotice || minitool.notice} +

+ ) : null}
- {missingScript ? ( -
-

- {`项目里还没有 \`${XHS_MINITOOL_SCRIPT_NAME}\` 脚本——这就是「还没适配」的判据。这件事只能由 code agent 在项目里实验完成(用哪版 vite 配置、入口在哪、怎么调打包脚本都由它定):`} -

- - 交给 code agent 适配 - -
- ) : null} - - {form ? ( - - ) : ( -

- {minitool.notice || '正在读取导出注册表…'} -

- )} - -
-

代码 zip

-

- {`点「导出」会重新运行 ${XHS_MINITOOL_SCRIPT_NAME} 覆盖这一份,不复用上次的产物。`} -

-
- - {XHS_MINITOOL_ARTIFACT_RELATIVE_PATH} - - - - 下载 - -
-
- - {conflict && form ? ( - - ) : null} - - {minitool.failure ? ( - void minitool.runExport()} - /> - ) : null} - - {downloadNotice || minitool.notice ? ( -

- {downloadNotice || minitool.notice} -

- ) : null} +
); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/PaneButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/PaneButton.tsx index d5d8f0889..c82a61b2d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/PaneButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/PaneButton.tsx @@ -1,34 +1,50 @@ import type { ReactNode } from 'react'; /** - * 面板里的小按钮。三种语气的样式收在这一处,别在每个卡片里各抄一遍 Tailwind 串。 + * 面板里的按钮。语气与尺寸收在这一处,别在每个卡片里各抄一遍 Tailwind 串。 + * + * `brand` 是小红书红;`hero` 只给「打包」那颗主行动按钮——整行宽、字号更大,是这一页唯一一个 + * 「点下去就产出东西」的动作;其余是次要动作(复制、下载、重试),尺寸跟发布面板 + * (`GameDistributionPublishPanel`)对齐:10px/14px 内边距、10px 圆角、600 字重。 */ export function PaneButton({ children, variant = 'ghost', + size = 'sm', disabled, title, ariaLabel, + className, onClick, }: { children: ReactNode; - variant?: 'primary' | 'ghost'; + variant?: 'primary' | 'ghost' | 'brand'; + size?: 'sm' | 'lg' | 'hero'; disabled?: boolean; title?: string; ariaLabel?: string; + className?: string; onClick: () => void; }) { const tone = - variant === 'primary' - ? 'border-[#c7653d] bg-[#c7653d] text-white hover:bg-[#b4562f]' - : 'border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'; + variant === 'brand' + ? 'border-[#ff2442] bg-[#ff2442] text-white hover:bg-[#e01e3a]' + : variant === 'primary' + ? 'border-[#c7653d] bg-[#c7653d] text-white hover:bg-[#b4562f]' + : 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'; + const sizing = + size === 'hero' + ? 'w-full justify-center gap-2 rounded-xl px-6 py-4 text-lg font-bold tracking-wide shadow-md' + : size === 'lg' + ? 'justify-center gap-2 rounded-xl px-6 py-3 text-base font-semibold shadow-sm' + : 'gap-1.5 rounded-[10px] px-3.5 py-2 text-xs font-semibold'; return (