导出面板:打包永远可点,没有脚本时当场给适配出口
- 「打包」不再因为没有脚本判据就变灰:有没有脚本是点下去那一刻才成立的事实,灰着等于把判断推给用户,而他手上没有办法核对; - 点下去先现算一次,没有脚本就把适配弹窗递过去(`AdaptPromptDialog`),两条出路摆在一起:让陶泥儿调通 / 先放着; - 面板读不到导出信息时不拿空表单糊弄,读失败与读取中分开显示,并照旧每 2 秒重试; - `PaneButton` 增加 `brand` 语气与 `hero` 尺寸,打包那颗按钮整行宽、小红书红,是这一页唯一的主行动。
This commit is contained in:
+234
-64
@@ -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<typeof useXhsMinitoolExport>;
|
||||
|
||||
/**
|
||||
* 一次失败的完整现场:给用户看的说法,加上原始载荷(「让 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<FormInvalid | null>(null);
|
||||
const [conflict, setConflict] = useState<SaveConflict | null>(null);
|
||||
const [failure, setFailure] = useState<XhsMinitoolFailureState | null>(null);
|
||||
const [failure, setFailure] = useState<XhsMinitoolCardFailure | null>(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<void> => {
|
||||
if (!projectPath) return;
|
||||
const refresh = useCallback(async (): Promise<boolean | null> => {
|
||||
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<void> => {
|
||||
if (!projectPath) return;
|
||||
async (next: XHSMiniToolExportForm, baseHash: string): Promise<boolean> => {
|
||||
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<void> => {
|
||||
/**
|
||||
* 跑一次构建:每次都重新跑脚本、重新产出 zip,没有"上次的产物还能用"这条捷径。
|
||||
*
|
||||
* 不碰 `running`:调用方决定这次构建是一次独立导出,还是「打包」里保存之后的收尾。
|
||||
*/
|
||||
const buildOnce = useCallback(async (): Promise<void> => {
|
||||
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<void> => {
|
||||
if (!projectPath) return;
|
||||
setRunning(true);
|
||||
try {
|
||||
await buildOnce();
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [projectPath, buildOnce]);
|
||||
|
||||
/**
|
||||
* 打包:先**现场复算**「有没有脚本」,再冲掉没保存的输入,最后跑构建。
|
||||
*
|
||||
* 「打包」永远可点(除了正在跑),因为「有脚本」是个当场才成立的事实:agent 可能刚把脚本写进
|
||||
* 项目、用户可能刚把上一份删掉。点一下就现算一次,比让用户先找别的地方核对再回来点便宜。
|
||||
*
|
||||
* 没有脚本(或这次读不到)时不报错了事,而是把阻断弹窗重新武装起来——用户点「打包」的本意就是
|
||||
* 「我要一份代码包」,这条路走不通时该当场把「让陶泥儿帮你调通」这个出口递给他。
|
||||
*
|
||||
* 这里不做任何表单校验——校验只发生在保存命令里(`persist`)。打包只关心「盘上的注册表是不是
|
||||
* 用户现在这份」:有未保存输入就先 flush,flush 回 `false`(字段不合法、撞上冲突、宿主故障)
|
||||
* 就停在表单上让用户处理;冲突或字段红字还没解决也一样停住。构建本身只认盘上的文件。
|
||||
*/
|
||||
const packageAll = useCallback(async (): Promise<void> => {
|
||||
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<void> => {
|
||||
async (instruction: string, sentNotice: string): Promise<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+48
@@ -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 (
|
||||
<ThemedModal
|
||||
open={open}
|
||||
ariaLabel="这个项目还没有配置好导出"
|
||||
onClose={onDoItMyself}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-[#3d1f10]">
|
||||
这个项目还没有配置好导出
|
||||
</h2>
|
||||
<p className="mt-2 text-xs leading-relaxed text-[#6f5848]">
|
||||
要让陶泥儿先在项目里帮你调通一次导出流程(用哪套配置、入口在哪、怎么打包都由它试出来)。
|
||||
调通之后,每次点「打包」就能直接生成代码包。
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<PaneButton onClick={onDoItMyself}>我自己来</PaneButton>
|
||||
<PaneButton variant="primary" onClick={onSendToAgent}>
|
||||
让陶泥儿帮我调通
|
||||
</PaneButton>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
+107
-111
@@ -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 (
|
||||
<div
|
||||
className="flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-auto border-[#e2cbb8] bg-[#fffdf9]/40 p-3 md:border-r"
|
||||
className="flex h-full min-h-0 min-w-0 flex-col border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r"
|
||||
aria-label="导出内容区"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{missingScript ? null : (
|
||||
<PaneButton
|
||||
variant={minitool.hasScript ? 'ghost' : 'primary'}
|
||||
onClick={minitool.adapt}
|
||||
>
|
||||
适配
|
||||
</PaneButton>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3">
|
||||
{form ? (
|
||||
<FormCard
|
||||
form={form}
|
||||
fieldError={minitool.fieldError}
|
||||
projectPath={projectPath}
|
||||
onChange={minitool.updateField}
|
||||
onDownloadIcon={downloadIcon}
|
||||
onAskAgentToFill={() => void minitool.fillForm()}
|
||||
/>
|
||||
) : !minitool.readSettled ? (
|
||||
<p className="text-xs text-[#988476]">正在读取导出信息…</p>
|
||||
) : // 读失败时上面不重复一句话:失败卡片拿着宿主的现场和下一步。
|
||||
minitool.failure ? null : (
|
||||
<p className="text-xs text-[#988476]">
|
||||
{minitool.notice || '没能读到导出信息,正在自动重试…'}
|
||||
</p>
|
||||
)}
|
||||
<PaneButton
|
||||
variant={minitool.hasScript ? 'primary' : 'ghost'}
|
||||
disabled={!minitool.hasScript || minitool.running}
|
||||
title={
|
||||
minitool.hasScript
|
||||
? `重新运行 ${XHS_MINITOOL_SCRIPT_NAME}`
|
||||
: `项目里还没有 ${XHS_MINITOOL_SCRIPT_NAME},用下面卡片里的按钮让 code agent 接入`
|
||||
}
|
||||
onClick={() => void minitool.runExport()}
|
||||
|
||||
<section
|
||||
className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4"
|
||||
aria-label="小工具代码包"
|
||||
>
|
||||
{minitool.running ? '导出中…' : '导出'}
|
||||
</PaneButton>
|
||||
<h3 className="text-sm font-bold text-[#3d1f10]">小工具代码包</h3>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{/*
|
||||
「打包」不因为有脚本判据没到位就变灰:有没有脚本是**点下去那一刻**才成立的事实
|
||||
(agent 可能刚写完、用户可能刚删掉)。灰着按钮等于把「能不能开始」的判断推给用户,
|
||||
而他手上没有任何办法去核对。点下去先现算一次,没有就当场把适配出口递过去。
|
||||
*/}
|
||||
<PaneButton
|
||||
variant="brand"
|
||||
size="hero"
|
||||
disabled={minitool.running}
|
||||
title={
|
||||
minitool.hasScript
|
||||
? '重新生成一份小红书小工具代码包'
|
||||
: '项目还没配置好导出,先让陶泥儿帮你调通'
|
||||
}
|
||||
onClick={() => void minitool.packageAll()}
|
||||
>
|
||||
{minitool.running ? '打包中…' : '打包'}
|
||||
</PaneButton>
|
||||
{/* 下载入口只在打包成功之后出现:宿主不报告磁盘上有没有产物,没打过就不给一个下不了的按钮。 */}
|
||||
{minitool.artifactReady ? (
|
||||
<PaneButton
|
||||
ariaLabel="下载代码包"
|
||||
className="self-start"
|
||||
onClick={downloadArtifact}
|
||||
>
|
||||
下载代码包
|
||||
</PaneButton>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{conflict && form ? (
|
||||
<ConflictCard
|
||||
key={conflict.contentHash}
|
||||
conflict={conflict}
|
||||
mine={form}
|
||||
onResolve={minitool.resolveConflict}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{minitool.failure ? (
|
||||
<FailureCard
|
||||
failure={minitool.failure}
|
||||
onAdapt={minitool.adapt}
|
||||
onRepair={minitool.repair}
|
||||
onRetry={() => void minitool.runExport()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{downloadNotice || minitool.notice ? (
|
||||
<p
|
||||
role="status"
|
||||
className="whitespace-pre-wrap break-all text-xs text-[#6f5848]"
|
||||
>
|
||||
{downloadNotice || minitool.notice}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{missingScript ? (
|
||||
<section
|
||||
aria-label="还没有构建脚本"
|
||||
className="flex flex-col items-start gap-2 rounded-xl border border-dashed border-[#e2cbb8] bg-[#fffaf5] p-3"
|
||||
>
|
||||
<p className="text-xs text-[#6f5848]">
|
||||
{`项目里还没有 \`${XHS_MINITOOL_SCRIPT_NAME}\` 脚本——这就是「还没适配」的判据。这件事只能由 code agent 在项目里实验完成(用哪版 vite 配置、入口在哪、怎么调打包脚本都由它定):`}
|
||||
</p>
|
||||
<PaneButton variant="primary" onClick={minitool.adapt}>
|
||||
交给 code agent 适配
|
||||
</PaneButton>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{form ? (
|
||||
<FormCard
|
||||
form={form}
|
||||
fieldError={minitool.fieldError}
|
||||
onChange={minitool.updateField}
|
||||
onDownloadIcon={downloadIcon}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-[#988476]">
|
||||
{minitool.notice || '正在读取导出注册表…'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section
|
||||
className="rounded-xl border border-[#e2cbb8] bg-[#fffaf5] p-3"
|
||||
aria-label="交付产物"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[#3d1f10]">代码 zip</h3>
|
||||
<p className="mt-1 text-xs text-[#988476]">
|
||||
{`点「导出」会重新运行 ${XHS_MINITOOL_SCRIPT_NAME} 覆盖这一份,不复用上次的产物。`}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-lg border border-[#e2cbb8] bg-white px-2 py-1.5 text-xs text-[#3d1f10]">
|
||||
{XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}
|
||||
label="产物路径"
|
||||
/>
|
||||
<PaneButton ariaLabel="下载代码 zip" onClick={downloadArtifact}>
|
||||
下载
|
||||
</PaneButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{conflict && form ? (
|
||||
<ConflictCard
|
||||
key={conflict.contentHash}
|
||||
conflict={conflict}
|
||||
mine={form}
|
||||
onResolve={minitool.resolveConflict}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{minitool.failure ? (
|
||||
<FailureCard
|
||||
failure={minitool.failure}
|
||||
onAdapt={minitool.adapt}
|
||||
onRepair={minitool.repair}
|
||||
onRetry={() => void minitool.runExport()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{downloadNotice || minitool.notice ? (
|
||||
<p
|
||||
role="status"
|
||||
className="whitespace-pre-wrap break-all text-xs text-[#6f5848]"
|
||||
>
|
||||
{downloadNotice || minitool.notice}
|
||||
</p>
|
||||
) : null}
|
||||
<AdaptPromptDialog
|
||||
open={showAdaptPrompt}
|
||||
onSendToAgent={minitool.adapt}
|
||||
onDoItMyself={minitool.dismissAdaptPrompt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-6
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
|
||||
{...(title ? { title } : {})}
|
||||
disabled={disabled === true}
|
||||
className={`inline-flex items-center gap-1 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-45 ${tone}`}
|
||||
className={`inline-flex items-center border transition disabled:cursor-not-allowed disabled:opacity-45 ${tone} ${sizing}${className ? ` ${className}` : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user